setup nix in CI, and reformat.
This commit is contained in:
@@ -10,10 +10,15 @@ jobs:
|
|||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
# Match the label of your self-hosted runner
|
# Match the label of your self-hosted runner
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable Nix flakes
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.config/nix
|
||||||
|
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||||
|
|
||||||
- name: Run Full Check Suite
|
- name: Run Full Check Suite
|
||||||
# Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally.
|
# Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally.
|
||||||
# 'task check' runs analyze, unit tests, widget tests, and integration tests.
|
# 'task check' runs analyze, unit tests, widget tests, and integration tests.
|
||||||
@@ -28,6 +33,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable Nix flakes
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.config/nix
|
||||||
|
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||||
|
|
||||||
- name: Build Linux
|
- name: Build Linux
|
||||||
# The Taskfile task 'build-linux' currently builds --debug.
|
# The Taskfile task 'build-linux' currently builds --debug.
|
||||||
# You can add a 'build-linux-release' task or override it here.
|
# You can add a 'build-linux-release' task or override it here.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ jobs:
|
|||||||
deploy-playstore:
|
deploy-playstore:
|
||||||
name: Build & Deploy to Play Store
|
name: Build & Deploy to Play Store
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
if: github.ref == 'refs/heads/main'
|
if: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -105,3 +105,4 @@ website/.hugo_build.lock
|
|||||||
.wget-hsts
|
.wget-hsts
|
||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
|
.claude*
|
||||||
|
|||||||
+2
-2
@@ -345,7 +345,7 @@ tasks:
|
|||||||
|
|
||||||
check-fast:
|
check-fast:
|
||||||
desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration)
|
desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration)
|
||||||
deps: [analyze, test, test-widget, check-hygiene]
|
deps: [analyze, test, check-hygiene]
|
||||||
|
|
||||||
check-hygiene:
|
check-hygiene:
|
||||||
desc: Verify that no forbidden files (like home dir config) are tracked
|
desc: Verify that no forbidden files (like home dir config) are tracked
|
||||||
@@ -370,7 +370,7 @@ tasks:
|
|||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||||
deps: [analyze, test-widget, build-linux, test]
|
deps: [analyze, build-linux, test]
|
||||||
cmds:
|
cmds:
|
||||||
- task: _integrations
|
- task: _integrations
|
||||||
- task: coverage
|
- task: coverage
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
jq
|
jq
|
||||||
sqlite
|
sqlite
|
||||||
python3 # used by stalwart-dev/start to pick random ports
|
python3 # used by stalwart-dev/start to pick random ports
|
||||||
|
tea # Gitea CLI
|
||||||
]);
|
]);
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|||||||
@@ -200,10 +200,7 @@ class EmailAddress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {if (name != null) 'name': name, 'email': email};
|
||||||
if (name != null) 'name': name,
|
|
||||||
'email': email,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -316,8 +313,11 @@ class SyncEmailsResult {
|
|||||||
final int skipped;
|
final int skipped;
|
||||||
final int bytesTransferred;
|
final int bytesTransferred;
|
||||||
|
|
||||||
static const zero =
|
static const zero = SyncEmailsResult(
|
||||||
SyncEmailsResult(fetched: 0, skipped: 0, bytesTransferred: 0);
|
fetched: 0,
|
||||||
|
skipped: 0,
|
||||||
|
bytesTransferred: 0,
|
||||||
|
);
|
||||||
|
|
||||||
SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult(
|
SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult(
|
||||||
fetched: fetched + other.fetched,
|
fetched: fetched + other.fetched,
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ abstract class EmailRepository {
|
|||||||
|
|
||||||
Future<EmailBody> getEmailBody(String emailId);
|
Future<EmailBody> getEmailBody(String emailId);
|
||||||
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
||||||
Future<void> setFlag(
|
Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
|
||||||
String emailId, {
|
|
||||||
bool? seen,
|
|
||||||
bool? flagged,
|
|
||||||
});
|
|
||||||
Future<void> moveEmail(String emailId, String destMailboxPath);
|
Future<void> moveEmail(String emailId, String destMailboxPath);
|
||||||
|
|
||||||
/// Deletes the email. Returns the path of the mailbox it was moved to
|
/// Deletes the email. Returns the path of the mailbox it was moved to
|
||||||
|
|||||||
@@ -113,11 +113,10 @@ class AccountDiscoveryServiceImpl implements AccountDiscoveryService {
|
|||||||
/// well-known endpoint nor the autoconfig XML was found.
|
/// well-known endpoint nor the autoconfig XML was found.
|
||||||
Future<ImapSmtpDiscovery?> _tryMxFallback(String domain) async {
|
Future<ImapSmtpDiscovery?> _tryMxFallback(String domain) async {
|
||||||
try {
|
try {
|
||||||
final url = Uri.https(
|
final url = Uri.https('dns.google', '/resolve', {
|
||||||
'dns.google',
|
'name': domain,
|
||||||
'/resolve',
|
'type': 'MX',
|
||||||
{'name': domain, 'type': 'MX'},
|
});
|
||||||
);
|
|
||||||
final resp = await _client.get(url).timeout(const Duration(seconds: 5));
|
final resp = await _client.get(url).timeout(const Duration(seconds: 5));
|
||||||
if (resp.statusCode != 200) return null;
|
if (resp.statusCode != 200) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ Future<bool> _defaultManageSieveProbe({
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
} catch (_) {/* best-effort */}
|
} catch (_) {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('ManageSieve probe failed for $host:$port — $e');
|
log('ManageSieve probe failed for $host:$port — $e');
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
final currentPath = cancelled
|
final currentPath = cancelled
|
||||||
? action.sourceMailboxPath
|
? action.sourceMailboxPath
|
||||||
: (action.destinationMailboxPath ?? action.sourceMailboxPath);
|
: (action.destinationMailboxPath ?? action.sourceMailboxPath);
|
||||||
await repo
|
await repo.restoreEmails([
|
||||||
.restoreEmails([original.copyWith(mailboxPath: currentPath)]);
|
original.copyWith(mailboxPath: currentPath),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Move it back to source.
|
// 3. Move it back to source.
|
||||||
|
|||||||
@@ -54,8 +54,13 @@ class AccountSyncManager {
|
|||||||
_imapConnect,
|
_imapConnect,
|
||||||
_syncLog,
|
_syncLog,
|
||||||
),
|
),
|
||||||
AccountType.jmap =>
|
AccountType.jmap => _JmapAccountSync(
|
||||||
_JmapAccountSync(account, _mailboxes, _emails, _accounts, _syncLog),
|
account,
|
||||||
|
_mailboxes,
|
||||||
|
_emails,
|
||||||
|
_accounts,
|
||||||
|
_syncLog,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
_active[account.id] = loop;
|
_active[account.id] = loop;
|
||||||
loop.start();
|
loop.start();
|
||||||
@@ -144,8 +149,9 @@ class _AccountSync implements _SyncLoop {
|
|||||||
while (_running) {
|
while (_running) {
|
||||||
final startedAt = DateTime.now();
|
final startedAt = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final (_SyncStats stats, String? capturedLog) =
|
final (_SyncStats stats, String? capturedLog) = await _runSync(
|
||||||
await _runSync(account.verbose);
|
account.verbose,
|
||||||
|
);
|
||||||
await _syncLog.log(
|
await _syncLog.log(
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -239,8 +245,10 @@ class _AccountSync implements _SyncLoop {
|
|||||||
// Check for expired snoozes and move them back to Inbox before syncing.
|
// Check for expired snoozes and move them back to Inbox before syncing.
|
||||||
await _emails.wakeUpEmails(account.id);
|
await _emails.wakeUpEmails(account.id);
|
||||||
|
|
||||||
final pendingFlushed =
|
final pendingFlushed = await _emails.flushPendingChanges(
|
||||||
await _emails.flushPendingChanges(account.id, password);
|
account.id,
|
||||||
|
password,
|
||||||
|
);
|
||||||
final mailboxesSynced = await _mailboxes.syncMailboxes(account.id);
|
final mailboxesSynced = await _mailboxes.syncMailboxes(account.id);
|
||||||
final mailboxes = await _mailboxes.observeMailboxes(account.id).first;
|
final mailboxes = await _mailboxes.observeMailboxes(account.id).first;
|
||||||
var emailResult = SyncEmailsResult.zero;
|
var emailResult = SyncEmailsResult.zero;
|
||||||
@@ -359,8 +367,9 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
while (_running) {
|
while (_running) {
|
||||||
final startedAt = DateTime.now();
|
final startedAt = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final (_SyncStats stats, String? capturedLog) =
|
final (_SyncStats stats, String? capturedLog) = await _runSync(
|
||||||
await _runSync(account.verbose);
|
account.verbose,
|
||||||
|
);
|
||||||
await _syncLog.log(
|
await _syncLog.log(
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -456,8 +465,10 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
await _emails.wakeUpEmails(account.id);
|
await _emails.wakeUpEmails(account.id);
|
||||||
|
|
||||||
// Drain outbound queue before pulling from server.
|
// Drain outbound queue before pulling from server.
|
||||||
final pendingFlushed =
|
final pendingFlushed = await _emails.flushPendingChanges(
|
||||||
await _emails.flushPendingChanges(account.id, password);
|
account.id,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
|
||||||
final mailboxesSynced = await _mailboxes.syncMailboxes(account.id);
|
final mailboxesSynced = await _mailboxes.syncMailboxes(account.id);
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ import 'package:sharedinbox/data/db/database.dart';
|
|||||||
/// Periodically verifies local state against the server's "ground truth".
|
/// Periodically verifies local state against the server's "ground truth".
|
||||||
/// Results are stored in the [SyncHealth] table.
|
/// Results are stored in the [SyncHealth] table.
|
||||||
class ReliabilityRunner {
|
class ReliabilityRunner {
|
||||||
ReliabilityRunner(
|
ReliabilityRunner(this._db, this._accounts, this._mailboxes, this._emails);
|
||||||
this._db,
|
|
||||||
this._accounts,
|
|
||||||
this._mailboxes,
|
|
||||||
this._emails,
|
|
||||||
);
|
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
@@ -65,8 +60,10 @@ class ReliabilityRunner {
|
|||||||
|
|
||||||
for (final mailbox in mailboxes) {
|
for (final mailbox in mailboxes) {
|
||||||
if (!_running) break;
|
if (!_running) break;
|
||||||
final result =
|
final result = await _emails.verifySyncReliability(
|
||||||
await _emails.verifySyncReliability(accountId, mailbox.path);
|
accountId,
|
||||||
|
mailbox.path,
|
||||||
|
);
|
||||||
if (!result.isHealthy) {
|
if (!result.isHealthy) {
|
||||||
totalMissingLocally += result.missingLocally.length;
|
totalMissingLocally += result.missingLocally.length;
|
||||||
totalMissingOnServer += result.missingOnServer.length;
|
totalMissingOnServer += result.missingOnServer.length;
|
||||||
|
|||||||
@@ -363,8 +363,9 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
emailIdsJson: Value(
|
emailIdsJson: Value(
|
||||||
jsonEncode(threadEmails.map((e) => e.id).toList()),
|
jsonEncode(threadEmails.map((e) => e.id).toList()),
|
||||||
),
|
),
|
||||||
participantsJson:
|
participantsJson: Value(
|
||||||
Value(latest.fromJson), // Good enough for migration
|
latest.fromJson,
|
||||||
|
), // Good enough for migration
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ class ManageSieveClient {
|
|||||||
await sub.cancel();
|
await sub.cancel();
|
||||||
try {
|
try {
|
||||||
await socket.close();
|
await socket.close();
|
||||||
} catch (_) {/* best-effort */}
|
} catch (_) {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,9 +129,7 @@ class ManageSieveClient {
|
|||||||
await _writeLine('AUTHENTICATE "PLAIN" "$initial"');
|
await _writeLine('AUTHENTICATE "PLAIN" "$initial"');
|
||||||
final resp = await _readResponse();
|
final resp = await _readResponse();
|
||||||
if (resp.status != _Status.ok) {
|
if (resp.status != _Status.ok) {
|
||||||
throw ManageSieveException(
|
throw ManageSieveException('Authentication failed: ${resp.message}');
|
||||||
'Authentication failed: ${resp.message}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,7 @@ class TlsModeMismatchException implements Exception {
|
|||||||
/// If [error] is a TLS handshake failure caused by a wrong-version-number
|
/// If [error] is a TLS handshake failure caused by a wrong-version-number
|
||||||
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
|
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
|
||||||
/// with [host]/[port] context. Otherwise rethrow [error] unchanged.
|
/// with [host]/[port] context. Otherwise rethrow [error] unchanged.
|
||||||
Never rethrowAsTlsHint(
|
Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) {
|
||||||
Object error,
|
|
||||||
StackTrace stack,
|
|
||||||
String host,
|
|
||||||
int port,
|
|
||||||
) {
|
|
||||||
if (error.toString().contains('WRONG_VERSION_NUMBER')) {
|
if (error.toString().contains('WRONG_VERSION_NUMBER')) {
|
||||||
Error.throwWithStackTrace(
|
Error.throwWithStackTrace(
|
||||||
TlsModeMismatchException(host, port, error),
|
TlsModeMismatchException(host, port, error),
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ class AccountRepositoryImpl implements AccountRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<model.Account>> observeAccounts() {
|
Stream<List<model.Account>> observeAccounts() {
|
||||||
return _db.select(_db.accounts).watch().map(
|
return _db
|
||||||
(rows) => rows.map(_toModel).toList(),
|
.select(_db.accounts)
|
||||||
);
|
.watch()
|
||||||
|
.map((rows) => rows.map(_toModel).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<model.Account?> getAccount(String id) async {
|
Future<model.Account?> getAccount(String id) async {
|
||||||
final row = await (_db.select(_db.accounts)..where((t) => t.id.equals(id)))
|
final row = await (_db.select(
|
||||||
|
_db.accounts,
|
||||||
|
)..where((t) => t.id.equals(id)))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
return row == null ? null : _toModel(row);
|
return row == null ? null : _toModel(row);
|
||||||
}
|
}
|
||||||
@@ -53,7 +56,9 @@ class AccountRepositoryImpl implements AccountRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateAccount(model.Account account, {String? password}) async {
|
Future<void> updateAccount(model.Account account, {String? password}) async {
|
||||||
await (_db.update(_db.accounts)..where((t) => t.id.equals(account.id)))
|
await (_db.update(
|
||||||
|
_db.accounts,
|
||||||
|
)..where((t) => t.id.equals(account.id)))
|
||||||
.write(
|
.write(
|
||||||
AccountsCompanion(
|
AccountsCompanion(
|
||||||
displayName: Value(account.displayName),
|
displayName: Value(account.displayName),
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SavedDraft?> getDraft(int id) async {
|
Future<SavedDraft?> getDraft(int id) async {
|
||||||
final row = await (_db.select(_db.drafts)..where((t) => t.id.equals(id)))
|
final row = await (_db.select(
|
||||||
|
_db.drafts,
|
||||||
|
)..where((t) => t.id.equals(id)))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
return row == null ? null : _toModel(row);
|
return row == null ? null : _toModel(row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,8 +72,11 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
account_model.Account account,
|
account_model.Account account,
|
||||||
String password,
|
String password,
|
||||||
) async {
|
) async {
|
||||||
final client =
|
final client = await _imapConnect(
|
||||||
await _imapConnect(account, _effectiveUsername(account), password);
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final mailboxes = await client.listMailboxes(recursive: true);
|
final mailboxes = await client.listMailboxes(recursive: true);
|
||||||
for (final mb in mailboxes) {
|
for (final mb in mailboxes) {
|
||||||
@@ -83,10 +86,10 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
var unread = 0;
|
var unread = 0;
|
||||||
var total = 0;
|
var total = 0;
|
||||||
try {
|
try {
|
||||||
final status = await client.statusMailbox(
|
final status = await client.statusMailbox(mb, [
|
||||||
mb,
|
imap.StatusFlags.messages,
|
||||||
[imap.StatusFlags.messages, imap.StatusFlags.unseen],
|
imap.StatusFlags.unseen,
|
||||||
);
|
]);
|
||||||
unread = status.messagesUnseen;
|
unread = status.messagesUnseen;
|
||||||
total = status.messagesExists;
|
total = status.messagesExists;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -145,7 +148,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
'Mailbox/get',
|
'Mailbox/get',
|
||||||
{'accountId': jmap.accountId, 'ids': null},
|
{'accountId': jmap.accountId, 'ids': null},
|
||||||
'0',
|
'0',
|
||||||
]
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final result = _responseArgs(responses, 0, 'Mailbox/get');
|
final result = _responseArgs(responses, 0, 'Mailbox/get');
|
||||||
@@ -154,7 +157,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
await _upsertJmapMailboxes(accountId, mailboxes);
|
await _upsertJmapMailboxes(accountId, mailboxes);
|
||||||
await _saveSyncState(accountId, 'Mailbox', newState);
|
await _saveSyncState(accountId, 'Mailbox', newState);
|
||||||
log('JMAP full mailbox sync: ${mailboxes.length} mailboxes, state=$newState');
|
log(
|
||||||
|
'JMAP full mailbox sync: ${mailboxes.length} mailboxes, state=$newState',
|
||||||
|
);
|
||||||
return mailboxes.length;
|
return mailboxes.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +174,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
'Mailbox/changes',
|
'Mailbox/changes',
|
||||||
{'accountId': jmap.accountId, 'sinceState': sinceState},
|
{'accountId': jmap.accountId, 'sinceState': sinceState},
|
||||||
'0',
|
'0',
|
||||||
]
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final changes = _responseArgs(responses, 0, 'Mailbox/changes');
|
final changes = _responseArgs(responses, 0, 'Mailbox/changes');
|
||||||
@@ -186,7 +191,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
'Mailbox/get',
|
'Mailbox/get',
|
||||||
{'accountId': jmap.accountId, 'ids': toFetch},
|
{'accountId': jmap.accountId, 'ids': toFetch},
|
||||||
'1',
|
'1',
|
||||||
]
|
],
|
||||||
]);
|
]);
|
||||||
final getResult = _responseArgs(getResponses, 0, 'Mailbox/get');
|
final getResult = _responseArgs(getResponses, 0, 'Mailbox/get');
|
||||||
await _upsertJmapMailboxes(accountId, getResult['list'] as List<dynamic>);
|
await _upsertJmapMailboxes(accountId, getResult['list'] as List<dynamic>);
|
||||||
@@ -194,14 +199,17 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
// Remove destroyed mailboxes
|
// Remove destroyed mailboxes
|
||||||
for (final jmapId in destroyed) {
|
for (final jmapId in destroyed) {
|
||||||
await (_db.delete(_db.mailboxes)
|
await (_db.delete(
|
||||||
..where((t) => t.id.equals('$accountId:$jmapId')))
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.id.equals('$accountId:$jmapId')))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _saveSyncState(accountId, 'Mailbox', newState);
|
await _saveSyncState(accountId, 'Mailbox', newState);
|
||||||
log('JMAP incremental mailbox sync: +${created.length} '
|
log(
|
||||||
'~${updated.length} -${destroyed.length}, state=$newState');
|
'JMAP incremental mailbox sync: +${created.length} '
|
||||||
|
'~${updated.length} -${destroyed.length}, state=$newState',
|
||||||
|
);
|
||||||
return toFetch.length + destroyed.length;
|
return toFetch.length + destroyed.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-5
@@ -99,7 +99,9 @@ final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
|
|||||||
final syncHealthProvider =
|
final syncHealthProvider =
|
||||||
StreamProvider.autoDispose.family<SyncHealthRow?, String>((ref, accountId) {
|
StreamProvider.autoDispose.family<SyncHealthRow?, String>((ref, accountId) {
|
||||||
final db = ref.watch(dbProvider);
|
final db = ref.watch(dbProvider);
|
||||||
return (db.select(db.syncHealth)..where((t) => t.accountId.equals(accountId)))
|
return (db.select(
|
||||||
|
db.syncHealth,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.watchSingleOrNull();
|
.watchSingleOrNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,8 +117,9 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
|
|||||||
return manager;
|
return manager;
|
||||||
});
|
});
|
||||||
|
|
||||||
final accountDiscoveryServiceProvider =
|
final accountDiscoveryServiceProvider = Provider<AccountDiscoveryService>((
|
||||||
Provider<AccountDiscoveryService>((ref) {
|
ref,
|
||||||
|
) {
|
||||||
return AccountDiscoveryServiceImpl(ref.watch(httpClientProvider));
|
return AccountDiscoveryServiceImpl(ref.watch(httpClientProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,8 +138,9 @@ final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final manageSieveProbeServiceProvider =
|
final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
||||||
Provider<ManageSieveProbeService>((ref) {
|
ref,
|
||||||
|
) {
|
||||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -33,20 +33,12 @@ void main({List<Override> overrides = const []}) async {
|
|||||||
|
|
||||||
await initDatabasePath();
|
await initDatabasePath();
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||||
overrides: overrides,
|
|
||||||
child: const SharedInboxApp(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(error, stack) {
|
(error, stack) {
|
||||||
// Catch unhandled async errors.
|
// Catch unhandled async errors.
|
||||||
runApp(
|
runApp(CrashScreen(exception: error, stackTrace: stack));
|
||||||
CrashScreen(
|
|
||||||
exception: error,
|
|
||||||
stackTrace: stack,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-10
@@ -49,9 +49,8 @@ final router = GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/sync-log',
|
path: ':accountId/sync-log',
|
||||||
builder: (ctx, state) => SyncLogScreen(
|
builder: (ctx, state) =>
|
||||||
accountId: state.pathParameters['accountId']!,
|
SyncLogScreen(accountId: state.pathParameters['accountId']!),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/sieve',
|
path: ':accountId/sieve',
|
||||||
@@ -68,9 +67,8 @@ final router = GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/search',
|
path: ':accountId/search',
|
||||||
builder: (ctx, state) => SearchScreen(
|
builder: (ctx, state) =>
|
||||||
accountId: state.pathParameters['accountId']!,
|
SearchScreen(accountId: state.pathParameters['accountId']!),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/emails/by-address/:address',
|
path: ':accountId/emails/by-address/:address',
|
||||||
@@ -116,10 +114,7 @@ final router = GoRouter(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(path: '/search', builder: (ctx, state) => const SearchScreen()),
|
||||||
path: '/search',
|
|
||||||
builder: (ctx, state) => const SearchScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/compose',
|
path: '/compose',
|
||||||
builder: (ctx, state) {
|
builder: (ctx, state) {
|
||||||
|
|||||||
@@ -188,9 +188,9 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
break;
|
break;
|
||||||
case _AccountAction.verifySync:
|
case _AccountAction.verifySync:
|
||||||
unawaited(
|
unawaited(
|
||||||
ProviderScope.containerOf(context)
|
ProviderScope.containerOf(
|
||||||
.read(reliabilityRunnerProvider)
|
context,
|
||||||
.checkNow(),
|
).read(reliabilityRunnerProvider).checkNow(),
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -225,9 +225,9 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if ((confirmed ?? false) && context.mounted) {
|
if ((confirmed ?? false) && context.mounted) {
|
||||||
await ProviderScope.containerOf(context)
|
await ProviderScope.containerOf(
|
||||||
.read(accountRepositoryProvider)
|
context,
|
||||||
.removeAccount(account.id);
|
).read(accountRepositoryProvider).removeAccount(account.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,9 +235,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
.addAccount(accountToSave, _passwordCtrl.text);
|
.addAccount(accountToSave, _passwordCtrl.text);
|
||||||
// Probe ManageSieve in the background; the menu starts visible (null)
|
// Probe ManageSieve in the background; the menu starts visible (null)
|
||||||
// and disappears on probe failure via the observeAccounts stream.
|
// and disappears on probe failure via the observeAccounts stream.
|
||||||
unawaited(
|
unawaited(ref.read(manageSieveProbeServiceProvider).probe(accountToSave));
|
||||||
ref.read(manageSieveProbeServiceProvider).probe(accountToSave),
|
|
||||||
);
|
|
||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -384,10 +382,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
onPressed: () => _tryConnection(_jmapFormKey, _buildJmapAccount),
|
onPressed: () => _tryConnection(_jmapFormKey, _buildJmapAccount),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(
|
FilledButton(onPressed: _saveJmap, child: const Text('Save')),
|
||||||
onPressed: _saveJmap,
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -439,10 +434,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
onPressed: () => _tryConnection(_imapFormKey, _buildImapAccount),
|
onPressed: () => _tryConnection(_imapFormKey, _buildImapAccount),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(
|
FilledButton(onPressed: _saveImap, child: const Text('Save')),
|
||||||
onPressed: _saveImap,
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -461,10 +453,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
_emailCtrl.text.trim(),
|
_emailCtrl.text.trim(),
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
Text(
|
Text(accountTypeLabel, style: Theme.of(context).textTheme.bodySmall),
|
||||||
accountTypeLabel,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
title: const Text('ChangeLog'),
|
|
||||||
),
|
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future: rootBundle.loadString('assets/changelog.txt'),
|
future: rootBundle.loadString('assets/changelog.txt'),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -39,10 +37,7 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
styleSheet: MarkdownStyleSheet(
|
styleSheet: MarkdownStyleSheet(
|
||||||
p: const TextStyle(
|
p: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -190,9 +190,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
await OpenFilex.open(path);
|
await OpenFilex.open(path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Failed to open file: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Failed to open file: $e')));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _opening = false);
|
if (mounted) setState(() => _opening = false);
|
||||||
}
|
}
|
||||||
@@ -204,9 +204,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
|
|
||||||
Future<void> _send() async {
|
Future<void> _send() async {
|
||||||
if (_accountId == null) {
|
if (_accountId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
const SnackBar(content: Text('Select an account first')),
|
context,
|
||||||
);
|
).showSnackBar(const SnackBar(content: Text('Select an account first')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _sending = true);
|
setState(() => _sending = true);
|
||||||
@@ -229,8 +229,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
.toList(),
|
.toList(),
|
||||||
subject: _subject.text,
|
subject: _subject.text,
|
||||||
body: _body.text,
|
body: _body.text,
|
||||||
attachmentFilePaths:
|
attachmentFilePaths: List.unmodifiable(
|
||||||
List.unmodifiable(_attachments.map((a) => a.path).toList()),
|
_attachments.map((a) => a.path).toList(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft);
|
await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft);
|
||||||
// Delete the draft only after a successful send.
|
// Delete the draft only after a successful send.
|
||||||
@@ -240,8 +241,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(
|
||||||
.showSnackBar(SnackBar(content: Text('Send failed: $e')));
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Send failed: $e')));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _sending = false);
|
if (mounted) setState(() => _sending = false);
|
||||||
}
|
}
|
||||||
@@ -257,10 +259,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text('Saved', style: TextStyle(fontSize: 12)),
|
||||||
'Saved',
|
|
||||||
style: TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ class CrashScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
||||||
Icons.error_outline,
|
|
||||||
color: Colors.red,
|
|
||||||
size: 64,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'SharedInbox encountered an unexpected error and needs to be restarted.',
|
'SharedInbox encountered an unexpected error and needs to be restarted.',
|
||||||
@@ -68,8 +64,10 @@ class CrashScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
stackTrace.toString(),
|
stackTrace.toString(),
|
||||||
style:
|
style: const TextStyle(
|
||||||
const TextStyle(fontFamily: 'monospace', fontSize: 10),
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -113,9 +111,9 @@ class CrashScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Error: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -213,9 +213,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
// Re-probe when the cached availability was cleared above.
|
// Re-probe when the cached availability was cleared above.
|
||||||
if (updated.type == AccountType.imap &&
|
if (updated.type == AccountType.imap &&
|
||||||
updated.manageSieveAvailable == null) {
|
updated.manageSieveAvailable == null) {
|
||||||
unawaited(
|
unawaited(ref.read(manageSieveProbeServiceProvider).probe(updated));
|
||||||
ref.read(manageSieveProbeServiceProvider).probe(updated),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -169,10 +169,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
if (header != null) ...[
|
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
||||||
_buildHeader(ctx, header),
|
|
||||||
const Divider(),
|
|
||||||
],
|
|
||||||
if (hasHtml) ...[
|
if (hasHtml) ...[
|
||||||
if (!_loadRemoteImages)
|
if (!_loadRemoteImages)
|
||||||
Align(
|
Align(
|
||||||
@@ -188,9 +185,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
Html(
|
Html(
|
||||||
data: body.htmlBody!,
|
data: body.htmlBody!,
|
||||||
extensions: [
|
extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()],
|
||||||
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -238,9 +233,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
await OpenFilex.open(path);
|
await OpenFilex.open(path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Opening file failed: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Opening file failed: $e')));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _downloading.remove(att.filename));
|
if (mounted) setState(() => _downloading.remove(att.filename));
|
||||||
}
|
}
|
||||||
@@ -430,8 +425,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
color: i.isEven
|
color: i.isEven
|
||||||
? Theme.of(ctx).colorScheme.surfaceContainerHighest
|
? Theme.of(ctx).colorScheme.surfaceContainerHighest
|
||||||
: Theme.of(ctx).colorScheme.surface,
|
: Theme.of(ctx).colorScheme.surface,
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(
|
||||||
const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
vertical: 4,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -442,10 +439,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(flex: 2, child: SelectableText(header.value)),
|
||||||
flex: 2,
|
|
||||||
child: SelectableText(header.value),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,11 +104,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
setState(() => _searchLoading = true);
|
setState(() => _searchLoading = true);
|
||||||
try {
|
try {
|
||||||
final results = await ref.read(emailRepositoryProvider).searchEmails(
|
final results = await ref
|
||||||
widget.accountId,
|
.read(emailRepositoryProvider)
|
||||||
widget.mailboxPath,
|
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
||||||
query.trim(),
|
|
||||||
);
|
|
||||||
if (mounted) setState(() => _searchResults = results);
|
if (mounted) setState(() => _searchResults = results);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _searchLoading = false);
|
if (mounted) setState(() => _searchLoading = false);
|
||||||
@@ -182,9 +180,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Sync failed: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Sync failed: $e')));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -288,10 +286,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
if (threads.isEmpty) {
|
if (threads.isEmpty) {
|
||||||
return ListView(
|
return ListView(
|
||||||
children: const [
|
children: const [
|
||||||
SizedBox(
|
SizedBox(height: 300, child: Center(child: Text('No emails'))),
|
||||||
height: 300,
|
|
||||||
child: Center(child: Text('No emails')),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -309,9 +304,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
.findMailboxByRole(widget.accountId, role);
|
.findMailboxByRole(widget.accountId, role);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (mailbox == null) {
|
if (mailbox == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(notFoundMessage)),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(notFoundMessage)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|||||||
@@ -78,8 +78,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
.where(
|
.where(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.accountId == accId &&
|
e.accountId == accId &&
|
||||||
[...e.from, ...e.to, ...e.cc]
|
[
|
||||||
.any((a) => a.email == addrEmail),
|
...e.from,
|
||||||
|
...e.to,
|
||||||
|
...e.cc,
|
||||||
|
].any((a) => a.email == addrEmail),
|
||||||
)
|
)
|
||||||
.length;
|
.length;
|
||||||
addresses.add((addr, count, accId));
|
addresses.add((addr, count, accId));
|
||||||
@@ -147,11 +150,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
if (r.addresses.isNotEmpty) ...[
|
if (r.addresses.isNotEmpty) ...[
|
||||||
const _SectionHeader('Addresses'),
|
const _SectionHeader('Addresses'),
|
||||||
for (final (addr, count, accId) in r.addresses)
|
for (final (addr, count, accId) in r.addresses)
|
||||||
_AddressTile(
|
_AddressTile(addr: addr, count: count, accountId: accId),
|
||||||
addr: addr,
|
|
||||||
count: count,
|
|
||||||
accountId: accId,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
if (r.emails.isNotEmpty) ...[
|
if (r.emails.isNotEmpty) ...[
|
||||||
const _SectionHeader('Messages'),
|
const _SectionHeader('Messages'),
|
||||||
|
|||||||
@@ -50,10 +50,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
Future<void> _loadContent() async {
|
Future<void> _loadContent() async {
|
||||||
setState(() => _loadingContent = true);
|
setState(() => _loadingContent = true);
|
||||||
try {
|
try {
|
||||||
final content = await ref.read(sieveRepositoryProvider).getScriptContent(
|
final content = await ref
|
||||||
widget.accountId,
|
.read(sieveRepositoryProvider)
|
||||||
widget.script!.blobId,
|
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||||
);
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_contentController.text = content;
|
_contentController.text = content;
|
||||||
setState(() => _loadingContent = false);
|
setState(() => _loadingContent = false);
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
await _load();
|
await _load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Failed to activate: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Failed to activate: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,9 +92,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
await _load();
|
await _load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Failed to delete: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,9 +106,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await context.push(
|
await context.push('/accounts/${widget.accountId}/sieve/edit');
|
||||||
'/accounts/${widget.accountId}/sieve/edit',
|
|
||||||
);
|
|
||||||
await _load();
|
await _load();
|
||||||
},
|
},
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
@@ -129,10 +127,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton(
|
FilledButton(onPressed: _load, child: const Text('Retry')),
|
||||||
onPressed: _load,
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -200,10 +195,7 @@ class _ScriptTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (_) => [
|
itemBuilder: (_) => [
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(value: _ScriptAction.edit, child: Text('Edit')),
|
||||||
value: _ScriptAction.edit,
|
|
||||||
child: Text('Edit'),
|
|
||||||
),
|
|
||||||
if (!script.isActive)
|
if (!script.isActive)
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: _ScriptAction.activate,
|
value: _ScriptAction.activate,
|
||||||
@@ -217,10 +209,7 @@ class _ScriptTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await context.push(
|
await context.push('/accounts/$accountId/sieve/edit', extra: script);
|
||||||
'/accounts/$accountId/sieve/edit',
|
|
||||||
extra: script,
|
|
||||||
);
|
|
||||||
onEdited();
|
onEdited();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -125,10 +125,7 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
entry.isOk
|
entry.isOk
|
||||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||||
: 'Error · took $durationLabel',
|
: 'Error · took $durationLabel',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
||||||
fontSize: 12,
|
|
||||||
color: entry.isOk ? null : errorColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
@@ -211,9 +208,7 @@ class _SyncLogTile extends StatelessWidget {
|
|||||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(child: Text(value, style: const TextStyle(fontSize: 12))),
|
||||||
child: Text(value, style: const TextStyle(fontSize: 12)),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ class ThreadDetailScreen extends ConsumerWidget {
|
|||||||
final repo = ref.watch(emailRepositoryProvider);
|
final repo = ref.watch(emailRepositoryProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('Thread')),
|
||||||
title: const Text('Thread'),
|
|
||||||
),
|
|
||||||
body: StreamBuilder<List<Email>>(
|
body: StreamBuilder<List<Email>>(
|
||||||
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
.read(undoServiceProvider.notifier)
|
.read(undoServiceProvider.notifier)
|
||||||
.undo(actionId: action.id);
|
.undo(actionId: action.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
const SnackBar(content: Text('Action undone.')),
|
context,
|
||||||
);
|
).showSnackBar(const SnackBar(content: Text('Action undone.')));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Undo'),
|
child: const Text('Undo'),
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Dockerfile for a Codeberg Runner with Nix and a non-root worker
|
|
||||||
FROM gitea/act_runner:latest-ubuntu
|
|
||||||
|
|
||||||
# Install Nix requirements and basic tools
|
|
||||||
RUN apt-get update && apt-get install -y curl xz-utils sudo && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Nix in single-user mode
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux \
|
|
||||||
--init none \
|
|
||||||
--no-confirm
|
|
||||||
|
|
||||||
ENV PATH="/nix/var/nix/profiles/default/bin:${PATH}"
|
|
||||||
RUN echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
|
|
||||||
|
|
||||||
# Create a restricted 'worker' user for running the actual CI jobs
|
|
||||||
RUN useradd -m -s /bin/bash worker && \
|
|
||||||
mkdir -p /home/worker && \
|
|
||||||
chown -R worker:worker /home/worker
|
|
||||||
|
|
||||||
# Allow the worker user to use Nix
|
|
||||||
RUN chown -R worker:worker /nix/var/nix/profiles/per-user/worker || true && \
|
|
||||||
chmod -R 777 /nix/store /nix/var/nix/db
|
|
||||||
|
|
||||||
# We still start as root so the act_runner entrypoint can initialize,
|
|
||||||
# but the 'act_runner' is configured to run jobs as a specific user if requested.
|
|
||||||
# However, by default, act_runner executes inside this container.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# SharedInbox CI Runner
|
|
||||||
|
|
||||||
This directory contains the configuration for a self-hosted Codeberg (Forgejo) Actions runner.
|
|
||||||
|
|
||||||
## Strategy: "Thin CI, Heavy Taskfile"
|
|
||||||
We use a self-hosted runner to bypass the resource limits of hosted CI. The CI workflow is a thin wrapper that invokes `nix develop --command task check`. This ensures that CI environments are identical to local development environments.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
- Docker and Docker Compose installed.
|
|
||||||
- Systemd (for persistence).
|
|
||||||
- A registration token from Codeberg (Settings > Actions > Runners).
|
|
||||||
- A `.env` file in the project root containing `CODEBERG_CI_RUNNER_TOKEN`.
|
|
||||||
|
|
||||||
## Installation & Setup
|
|
||||||
|
|
||||||
Run these commands as a user with `sudo` to install the runner on your laptop or VPS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create the system directory
|
|
||||||
sudo mkdir -p /opt/sharedinbox-runner
|
|
||||||
|
|
||||||
# 2. Copy the runner configuration and your .env file
|
|
||||||
# (Run this from the root of your local sharedinbox3 project)
|
|
||||||
sudo cp -r sharedinbox-runner /opt/sharedinbox-runner/
|
|
||||||
sudo cp .env /opt/sharedinbox-runner/
|
|
||||||
|
|
||||||
# 3. Install the systemd service
|
|
||||||
sudo cp /opt/sharedinbox-runner/sharedinbox-runner/sharedinbox-runner.service /etc/systemd/system/
|
|
||||||
|
|
||||||
# 4. Reload systemd and start the service
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now sharedinbox-runner.service
|
|
||||||
```
|
|
||||||
|
|
||||||
## Management
|
|
||||||
- **Check status:** `systemctl status sharedinbox-runner.service`
|
|
||||||
- **View logs:** `journalctl -u sharedinbox-runner.service -f`
|
|
||||||
- **Restart:** `sudo systemctl restart sharedinbox-runner.service`
|
|
||||||
- **Stop:** `sudo systemctl stop sharedinbox-runner.service`
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
services:
|
|
||||||
runner:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: always
|
|
||||||
env_file:
|
|
||||||
- ../.env
|
|
||||||
environment:
|
|
||||||
- GITEA_INSTANCE_URL=${CODEBERG_INSTANCE_URL:-https://codeberg.org}
|
|
||||||
- GITEA_RUNNER_REGISTRATION_TOKEN=${CODEBERG_CI_RUNNER_TOKEN}
|
|
||||||
- GITEA_RUNNER_NAME=${CODEBERG_RUNNER_NAME:-laptop-runner}
|
|
||||||
- GITEA_RUNNER_LABELS=${CODEBERG_RUNNER_LABELS:-self-hosted,linux,nix}
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ./runner-data:/data
|
|
||||||
# Use host network if you want to access local services easily,
|
|
||||||
# but for most cases the default bridge is fine.
|
|
||||||
# network_mode: host
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=SharedInbox CI Runner (Docker Compose)
|
|
||||||
Requires=docker.service
|
|
||||||
After=docker.service network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
WorkingDirectory=/
|
|
||||||
ExecStartPre=-/usr/bin/docker compose -f /opt/sharedinbox-runner/sharedinbox-runner/docker-compose.yml down
|
|
||||||
ExecStart=/usr/bin/docker compose -f /opt/sharedinbox-runner/sharedinbox-runner/docker-compose.yml up --build
|
|
||||||
ExecStop=/usr/bin/docker compose -f /opt/sharedinbox-runner/sharedinbox-runner/docker-compose.yml down
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -126,144 +126,148 @@ void main() {
|
|||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('concurrent IMAP + JMAP sync caches all emails without errors',
|
test(
|
||||||
timeout: const Timeout(Duration(seconds: 30)), () async {
|
'concurrent IMAP + JMAP sync caches all emails without errors',
|
||||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
timeout: const Timeout(Duration(seconds: 30)),
|
||||||
const msgCount = 2;
|
() async {
|
||||||
|
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
const msgCount = 2;
|
||||||
|
|
||||||
// ── 1. Send emails in both directions ─────────────────────────────────────
|
// ── 1. Send emails in both directions ─────────────────────────────────────
|
||||||
// alice → bob (alice uses IMAP; bob uses JMAP)
|
// alice → bob (alice uses IMAP; bob uses JMAP)
|
||||||
// bob → alice (cross-direction)
|
// bob → alice (cross-direction)
|
||||||
for (var i = 0; i < msgCount; i++) {
|
for (var i = 0; i < msgCount; i++) {
|
||||||
await _sendMessage(
|
await _sendMessage(
|
||||||
host: imapHost,
|
host: imapHost,
|
||||||
port: smtpPort,
|
port: smtpPort,
|
||||||
from: aliceUser,
|
from: aliceUser,
|
||||||
pass: alicePass,
|
pass: alicePass,
|
||||||
to: bobUser,
|
to: bobUser,
|
||||||
subject: 'alice-to-bob-$ts-$i',
|
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,
|
||||||
);
|
);
|
||||||
await _sendMessage(
|
final bobAccount = model.Account(
|
||||||
host: imapHost,
|
id: 'bob',
|
||||||
port: smtpPort,
|
displayName: 'Bob',
|
||||||
from: bobUser,
|
email: bobUser,
|
||||||
pass: bobPass,
|
type: model.AccountType.jmap,
|
||||||
to: aliceUser,
|
jmapUrl: '$jmapUrl/.well-known/jmap',
|
||||||
subject: 'bob-to-alice-$ts-$i',
|
smtpHost: imapHost,
|
||||||
|
smtpPort: smtpPort,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
// Give Stalwart a moment to deliver all messages.
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
|
||||||
|
|
||||||
// ── 2. Insert accounts ─────────────────────────────────────────────────────
|
await accounts.addAccount(aliceAccount, alicePass);
|
||||||
final aliceAccount = model.Account(
|
await accounts.addAccount(bobAccount, bobPass);
|
||||||
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);
|
final httpClient = http.Client();
|
||||||
await accounts.addAccount(bobAccount, bobPass);
|
addTearDown(httpClient.close);
|
||||||
|
|
||||||
final httpClient = http.Client();
|
final mailboxRepo = MailboxRepositoryImpl(
|
||||||
addTearDown(httpClient.close);
|
db,
|
||||||
|
accounts,
|
||||||
|
imapConnect: _connectImapPlaintext,
|
||||||
|
httpClient: httpClient,
|
||||||
|
);
|
||||||
|
final emailRepo = EmailRepositoryImpl(
|
||||||
|
db,
|
||||||
|
accounts,
|
||||||
|
imapConnect: _connectImapPlaintext,
|
||||||
|
httpClient: httpClient,
|
||||||
|
);
|
||||||
|
|
||||||
final mailboxRepo = MailboxRepositoryImpl(
|
// ── 3. Sync mailboxes concurrently ─────────────────────────────────────────
|
||||||
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([
|
await Future.wait([
|
||||||
emailRepo.syncEmails('alice', aliceInbox),
|
mailboxRepo.syncMailboxes('alice'),
|
||||||
emailRepo.syncEmails('bob', bobInbox),
|
mailboxRepo.syncMailboxes('bob'),
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
// ── 5. Verify DB consistency ───────────────────────────────────────────────
|
final allMailboxes = await db.select(db.mailboxes).get();
|
||||||
final allEmails = await db.select(db.emails).get();
|
expect(
|
||||||
|
allMailboxes,
|
||||||
|
isNotEmpty,
|
||||||
|
reason: 'mailboxes should be cached after sync',
|
||||||
|
);
|
||||||
|
|
||||||
// No duplicate email IDs.
|
// Grab INBOX paths for each account.
|
||||||
final ids = allEmails.map((e) => e.id).toList();
|
// IMAP: path is the mailbox path string (e.g. "INBOX").
|
||||||
expect(
|
// JMAP: path is the server-assigned JMAP mailbox ID; match by role or name.
|
||||||
ids.toSet().length,
|
final aliceInbox = allMailboxes
|
||||||
equals(ids.length),
|
.firstWhere(
|
||||||
reason: 'duplicate email IDs in DB',
|
(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;
|
||||||
|
|
||||||
// Alice and bob each received at least msgCount messages.
|
// ── 4. Sync emails concurrently — run twice to exercise incremental sync ───
|
||||||
final aliceEmails = allEmails.where((e) => e.accountId == 'alice').toList();
|
for (var round = 0; round < 2; round++) {
|
||||||
final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList();
|
await Future.wait([
|
||||||
expect(
|
emailRepo.syncEmails('alice', aliceInbox),
|
||||||
aliceEmails.length,
|
emailRepo.syncEmails('bob', bobInbox),
|
||||||
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.
|
// ── 5. Verify DB consistency ───────────────────────────────────────────────
|
||||||
for (final e in allEmails) {
|
final allEmails = await db.select(db.emails).get();
|
||||||
expect(e.accountId, isNotEmpty);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No pending changes left in the queue.
|
// No duplicate email IDs.
|
||||||
final pending = await db.select(db.pendingChanges).get();
|
final ids = allEmails.map((e) => e.id).toList();
|
||||||
expect(pending, isEmpty, reason: 'no outbound mutations expected');
|
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');
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ Future<ImapClient> _imapConnect({
|
|||||||
required String user,
|
required String user,
|
||||||
required String pass,
|
required String pass,
|
||||||
}) async {
|
}) async {
|
||||||
final client =
|
final client = ImapClient(
|
||||||
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
defaultResponseTimeout: const Duration(seconds: 20),
|
||||||
|
);
|
||||||
await client.connectToServer(host, port, isSecure: false);
|
await client.connectToServer(host, port, isSecure: false);
|
||||||
await client.login(user, pass);
|
await client.login(user, pass);
|
||||||
return client;
|
return client;
|
||||||
@@ -113,8 +114,9 @@ void main() {
|
|||||||
String username,
|
String username,
|
||||||
String password,
|
String password,
|
||||||
) async {
|
) async {
|
||||||
final client =
|
final client = ImapClient(
|
||||||
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
defaultResponseTimeout: const Duration(seconds: 20),
|
||||||
|
);
|
||||||
await client.connectToServer(a.imapHost, a.imapPort, isSecure: false);
|
await client.connectToServer(a.imapHost, a.imapPort, isSecure: false);
|
||||||
await client.login(username, password);
|
await client.login(username, password);
|
||||||
return client;
|
return client;
|
||||||
@@ -200,44 +202,47 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'syncEmails incremental sync fetches only messages newer than checkpoint',
|
'syncEmails incremental sync fetches only messages newer than checkpoint',
|
||||||
() async {
|
() async {
|
||||||
await appendToInbox('first');
|
await appendToInbox('first');
|
||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
await r.emails.syncEmails('test', 'INBOX');
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final afterFirst = await r.emails.observeEmails('test', 'INBOX').first;
|
final afterFirst = await r.emails.observeEmails('test', 'INBOX').first;
|
||||||
expect(afterFirst, hasLength(1));
|
expect(afterFirst, hasLength(1));
|
||||||
expect(afterFirst.first.subject, 'first');
|
expect(afterFirst.first.subject, 'first');
|
||||||
|
|
||||||
await appendToInbox('second');
|
await appendToInbox('second');
|
||||||
await r.emails.syncEmails('test', 'INBOX');
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final afterSecond = await r.emails.observeEmails('test', 'INBOX').first;
|
final afterSecond = await r.emails.observeEmails('test', 'INBOX').first;
|
||||||
expect(afterSecond, hasLength(2));
|
expect(afterSecond, hasLength(2));
|
||||||
expect(afterSecond.map((e) => e.subject).toSet(), {'first', 'second'});
|
expect(afterSecond.map((e) => e.subject).toSet(), {'first', 'second'});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('CONDSTORE fast-path: second sync skips fetch when nothing changed',
|
test(
|
||||||
() async {
|
'CONDSTORE fast-path: second sync skips fetch when nothing changed',
|
||||||
await appendToInbox('condstore-test');
|
() async {
|
||||||
|
await appendToInbox('condstore-test');
|
||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
|
|
||||||
// First sync — full sync, saves modseq checkpoint.
|
// First sync — full sync, saves modseq checkpoint.
|
||||||
await r.emails.syncEmails('test', 'INBOX');
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
final stateAfterFirst = await r.db.select(r.db.syncStates).get();
|
final stateAfterFirst = await r.db.select(r.db.syncStates).get();
|
||||||
expect(stateAfterFirst, hasLength(1));
|
expect(stateAfterFirst, hasLength(1));
|
||||||
|
|
||||||
// Second sync with no server changes — CONDSTORE fast-path should skip
|
// Second sync with no server changes — CONDSTORE fast-path should skip
|
||||||
// fetching. DB email count must stay the same.
|
// fetching. DB email count must stay the same.
|
||||||
await r.emails.syncEmails('test', 'INBOX');
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||||
expect(emails, hasLength(1));
|
expect(emails, hasLength(1));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('CONDSTORE flag refresh updates flags in local DB', () async {
|
test('CONDSTORE flag refresh updates flags in local DB', () async {
|
||||||
await appendToInbox('flag-refresh-test');
|
await appendToInbox('flag-refresh-test');
|
||||||
@@ -258,10 +263,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await imap.selectMailboxByPath('INBOX');
|
await imap.selectMailboxByPath('INBOX');
|
||||||
final seq = MessageSequence.fromIds(
|
final seq = MessageSequence.fromIds([emails.first.uid], isUid: true);
|
||||||
[emails.first.uid],
|
|
||||||
isUid: true,
|
|
||||||
);
|
|
||||||
await imap.uidMarkSeen(seq);
|
await imap.uidMarkSeen(seq);
|
||||||
} finally {
|
} finally {
|
||||||
await imap.logout();
|
await imap.logout();
|
||||||
@@ -330,53 +332,57 @@ void main() {
|
|||||||
expect(cached.textBody, body.textBody);
|
expect(cached.textBody, body.textBody);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('blob expiry: re-fetches body when cachedAt is null (legacy row)',
|
test(
|
||||||
() async {
|
'blob expiry: re-fetches body when cachedAt is null (legacy row)',
|
||||||
await appendToInbox('legacy-body-test', body: 'Fresh from server');
|
() async {
|
||||||
|
await appendToInbox('legacy-body-test', body: 'Fresh from server');
|
||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
await r.emails.syncEmails('test', 'INBOX');
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||||
final emailId = emails.first.id;
|
final emailId = emails.first.id;
|
||||||
|
|
||||||
// Simulate a legacy row with no cachedAt.
|
// Simulate a legacy row with no cachedAt.
|
||||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||||
EmailBodiesCompanion.insert(
|
EmailBodiesCompanion.insert(
|
||||||
emailId: emailId,
|
emailId: emailId,
|
||||||
textBody: const Value('stale text'),
|
textBody: const Value('stale text'),
|
||||||
cachedAt: const Value(null),
|
cachedAt: const Value(null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final body = await r.emails.getEmailBody(emailId);
|
final body = await r.emails.getEmailBody(emailId);
|
||||||
expect(body.textBody, contains('Fresh from server'));
|
expect(body.textBody, contains('Fresh from server'));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('blob expiry: re-fetches body when cachedAt is older than 7 days',
|
test(
|
||||||
() async {
|
'blob expiry: re-fetches body when cachedAt is older than 7 days',
|
||||||
await appendToInbox('old-body-test', body: 'Current content');
|
() async {
|
||||||
|
await appendToInbox('old-body-test', body: 'Current content');
|
||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
await r.emails.syncEmails('test', 'INBOX');
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||||
final emailId = emails.first.id;
|
final emailId = emails.first.id;
|
||||||
|
|
||||||
// Simulate a row cached 8 days ago.
|
// Simulate a row cached 8 days ago.
|
||||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||||
EmailBodiesCompanion.insert(
|
EmailBodiesCompanion.insert(
|
||||||
emailId: emailId,
|
emailId: emailId,
|
||||||
textBody: const Value('old text'),
|
textBody: const Value('old text'),
|
||||||
cachedAt: Value(DateTime.now().subtract(const Duration(days: 8))),
|
cachedAt: Value(DateTime.now().subtract(const Duration(days: 8))),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final body = await r.emails.getEmailBody(emailId);
|
final body = await r.emails.getEmailBody(emailId);
|
||||||
expect(body.textBody, contains('Current content'));
|
expect(body.textBody, contains('Current content'));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('sendEmail delivers via SMTP and appends copy to Sent folder', () async {
|
test('sendEmail delivers via SMTP and appends copy to Sent folder', () async {
|
||||||
final subject = 'send-${DateTime.now().millisecondsSinceEpoch}';
|
final subject = 'send-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
@@ -426,8 +432,11 @@ void main() {
|
|||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
|
|
||||||
final results =
|
final results = await r.emails.searchEmails(
|
||||||
await r.emails.searchEmails('test', 'INBOX', 'xyzzy-no-match');
|
'test',
|
||||||
|
'INBOX',
|
||||||
|
'xyzzy-no-match',
|
||||||
|
);
|
||||||
expect(results, isEmpty);
|
expect(results, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ Future<ImapClient> _imapConnect({
|
|||||||
required String user,
|
required String user,
|
||||||
required String pass,
|
required String pass,
|
||||||
}) async {
|
}) async {
|
||||||
final client =
|
final client = ImapClient(
|
||||||
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
defaultResponseTimeout: const Duration(seconds: 20),
|
||||||
|
);
|
||||||
await client.connectToServer(host, port, isSecure: false);
|
await client.connectToServer(host, port, isSecure: false);
|
||||||
await client.login(user, pass);
|
await client.login(user, pass);
|
||||||
return client;
|
return client;
|
||||||
@@ -172,24 +173,26 @@ void main() {
|
|||||||
expect(emails.first.isSeen, isFalse);
|
expect(emails.first.isSeen, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('syncEmails saves state and incremental sync picks up new messages',
|
test(
|
||||||
() async {
|
'syncEmails saves state and incremental sync picks up new messages',
|
||||||
final r = makeRepo();
|
() async {
|
||||||
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
|
final r = makeRepo();
|
||||||
|
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
|
||||||
|
|
||||||
await appendToInbox('first');
|
await appendToInbox('first');
|
||||||
await r.emails.syncEmails('test-jmap', inboxId);
|
await r.emails.syncEmails('test-jmap', inboxId);
|
||||||
expect(
|
expect(
|
||||||
await r.emails.observeEmails('test-jmap', inboxId).first,
|
await r.emails.observeEmails('test-jmap', inboxId).first,
|
||||||
hasLength(1),
|
hasLength(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
await appendToInbox('second');
|
await appendToInbox('second');
|
||||||
await r.emails.syncEmails('test-jmap', inboxId);
|
await r.emails.syncEmails('test-jmap', inboxId);
|
||||||
final emails = await r.emails.observeEmails('test-jmap', inboxId).first;
|
final emails = await r.emails.observeEmails('test-jmap', inboxId).first;
|
||||||
expect(emails, hasLength(2));
|
expect(emails, hasLength(2));
|
||||||
expect(emails.map((e) => e.subject).toSet(), {'first', 'second'});
|
expect(emails.map((e) => e.subject).toSet(), {'first', 'second'});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('syncEmails removes email deleted on server from local DB', () async {
|
test('syncEmails removes email deleted on server from local DB', () async {
|
||||||
await appendToInbox('keep');
|
await appendToInbox('keep');
|
||||||
@@ -247,42 +250,44 @@ void main() {
|
|||||||
expect(cached.textBody, body.textBody);
|
expect(cached.textBody, body.textBody);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sendEmail submits via JMAP EmailSubmission and creates Sent copy',
|
test(
|
||||||
() async {
|
'sendEmail submits via JMAP EmailSubmission and creates Sent copy',
|
||||||
final subject = 'jmap-send-${DateTime.now().millisecondsSinceEpoch}';
|
() async {
|
||||||
final r = makeRepo();
|
final subject = 'jmap-send-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
await r.accounts.addAccount(account, userPass);
|
final r = makeRepo();
|
||||||
await r.mailboxes.syncMailboxes('test-jmap');
|
await r.accounts.addAccount(account, userPass);
|
||||||
|
await r.mailboxes.syncMailboxes('test-jmap');
|
||||||
|
|
||||||
await r.emails.sendEmail(
|
await r.emails.sendEmail(
|
||||||
'test-jmap',
|
'test-jmap',
|
||||||
EmailDraft(
|
EmailDraft(
|
||||||
from: EmailAddress(name: 'Alice', email: userEmail),
|
from: EmailAddress(name: 'Alice', email: userEmail),
|
||||||
to: [EmailAddress(name: 'Alice', email: userEmail)],
|
to: [EmailAddress(name: 'Alice', email: userEmail)],
|
||||||
cc: [],
|
cc: [],
|
||||||
subject: subject,
|
subject: subject,
|
||||||
body: 'Integration test message via JMAP',
|
body: 'Integration test message via JMAP',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// A sent copy should appear in the Sent mailbox.
|
// A sent copy should appear in the Sent mailbox.
|
||||||
final sentRow = await (r.db.select(r.db.mailboxes)
|
final sentRow = await (r.db.select(r.db.mailboxes)
|
||||||
..where(
|
..where(
|
||||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('sent'),
|
(t) => t.accountId.equals('test-jmap') & t.role.equals('sent'),
|
||||||
)
|
)
|
||||||
..limit(1))
|
..limit(1))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
final sentId = sentRow?.path;
|
final sentId = sentRow?.path;
|
||||||
|
|
||||||
if (sentId != null) {
|
if (sentId != null) {
|
||||||
await r.emails.syncEmails('test-jmap', sentId);
|
await r.emails.syncEmails('test-jmap', sentId);
|
||||||
final sentEmails =
|
final sentEmails =
|
||||||
await r.emails.observeEmails('test-jmap', sentId).first;
|
await r.emails.observeEmails('test-jmap', sentId).first;
|
||||||
expect(sentEmails.any((e) => e.subject == subject), isTrue);
|
expect(sentEmails.any((e) => e.subject == subject), isTrue);
|
||||||
} else {
|
} else {
|
||||||
// If no Sent mailbox exists, just verify sendEmail didn't throw.
|
// If no Sent mailbox exists, just verify sendEmail didn't throw.
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('flushPendingChanges marks email as seen on server', () async {
|
test('flushPendingChanges marks email as seen on server', () async {
|
||||||
await appendToInbox('flag-test');
|
await appendToInbox('flag-test');
|
||||||
|
|||||||
@@ -45,12 +45,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('login and list mailboxes', () async {
|
test('login and list mailboxes', () async {
|
||||||
final client = await _connect(
|
final client = await _connect(userA, passA, host: imapHost, port: imapPort);
|
||||||
userA,
|
|
||||||
passA,
|
|
||||||
host: imapHost,
|
|
||||||
port: imapPort,
|
|
||||||
);
|
|
||||||
addTearDown(() => client.logout().ignore());
|
addTearDown(() => client.logout().ignore());
|
||||||
|
|
||||||
// listMailboxes() returns List<Mailbox> directly
|
// listMailboxes() returns List<Mailbox> directly
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ Future<ImapClient> _imapConnect({
|
|||||||
required String user,
|
required String user,
|
||||||
required String pass,
|
required String pass,
|
||||||
}) async {
|
}) async {
|
||||||
final client =
|
final client = ImapClient(
|
||||||
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
defaultResponseTimeout: const Duration(seconds: 20),
|
||||||
|
);
|
||||||
await client.connectToServer(host, port, isSecure: false);
|
await client.connectToServer(host, port, isSecure: false);
|
||||||
await client.login(user, pass);
|
await client.login(user, pass);
|
||||||
return client;
|
return client;
|
||||||
@@ -63,8 +64,9 @@ void main() {
|
|||||||
String username,
|
String username,
|
||||||
String password,
|
String password,
|
||||||
) async {
|
) async {
|
||||||
final client =
|
final client = ImapClient(
|
||||||
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
defaultResponseTimeout: const Duration(seconds: 20),
|
||||||
|
);
|
||||||
await client.connectToServer(a.imapHost, a.imapPort, isSecure: false);
|
await client.connectToServer(a.imapHost, a.imapPort, isSecure: false);
|
||||||
await client.login(username, password);
|
await client.login(username, password);
|
||||||
return client;
|
return client;
|
||||||
@@ -73,7 +75,7 @@ void main() {
|
|||||||
({
|
({
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
AccountRepositoryImpl accounts,
|
AccountRepositoryImpl accounts,
|
||||||
MailboxRepositoryImpl mailboxes
|
MailboxRepositoryImpl mailboxes,
|
||||||
}) makeRepo() {
|
}) makeRepo() {
|
||||||
final db = openTestDatabase();
|
final db = openTestDatabase();
|
||||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||||
|
|||||||
@@ -6,15 +6,11 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import '../../scripts/sync_reliability.dart' as reliability;
|
import '../../scripts/sync_reliability.dart' as reliability;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test(
|
test('sync reliability script runner', timeout: Timeout.none, () async {
|
||||||
'sync reliability script runner',
|
final rawArgs = Platform.environment['SYNC_RELIABILITY_ARGS'];
|
||||||
timeout: Timeout.none,
|
final args = rawArgs == null || rawArgs.isEmpty
|
||||||
() async {
|
? const <String>[]
|
||||||
final rawArgs = Platform.environment['SYNC_RELIABILITY_ARGS'];
|
: const LineSplitter().convert(rawArgs);
|
||||||
final args = rawArgs == null || rawArgs.isEmpty
|
await reliability.runSyncReliability(args);
|
||||||
? const <String>[]
|
});
|
||||||
: const LineSplitter().convert(rawArgs);
|
|
||||||
await reliability.runSyncReliability(args);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ Future<ImapClient> _imapConnectPlain(
|
|||||||
String username,
|
String username,
|
||||||
String password,
|
String password,
|
||||||
) async {
|
) async {
|
||||||
final client =
|
final client = ImapClient(
|
||||||
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
defaultResponseTimeout: const Duration(seconds: 20),
|
||||||
|
);
|
||||||
await client.connectToServer(
|
await client.connectToServer(
|
||||||
account.imapHost,
|
account.imapHost,
|
||||||
account.imapPort,
|
account.imapPort,
|
||||||
@@ -64,11 +65,7 @@ void main() {
|
|||||||
secureStorage = MapSecureStorage();
|
secureStorage = MapSecureStorage();
|
||||||
final accounts = AccountRepositoryImpl(db, secureStorage);
|
final accounts = AccountRepositoryImpl(db, secureStorage);
|
||||||
await accounts.addAccount(account, userPass);
|
await accounts.addAccount(account, userPass);
|
||||||
repo = EmailRepositoryImpl(
|
repo = EmailRepositoryImpl(db, accounts, imapConnect: _imapConnectPlain);
|
||||||
db,
|
|
||||||
accounts,
|
|
||||||
imapConnect: _imapConnectPlain,
|
|
||||||
);
|
|
||||||
|
|
||||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||||
await client.selectMailboxByPath('INBOX');
|
await client.selectMailboxByPath('INBOX');
|
||||||
@@ -107,26 +104,27 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'verifySyncReliability identifies extra local emails (missing on server)',
|
'verifySyncReliability identifies extra local emails (missing on server)',
|
||||||
() async {
|
() async {
|
||||||
// 1. Manually insert a row into local DB that doesn't exist on server
|
// 1. Manually insert a row into local DB that doesn't exist on server
|
||||||
await db.into(db.emails).insert(
|
await db.into(db.emails).insert(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
id: 'test:999',
|
id: 'test:999',
|
||||||
accountId: 'test',
|
accountId: 'test',
|
||||||
mailboxPath: 'INBOX',
|
mailboxPath: 'INBOX',
|
||||||
uid: 999,
|
uid: 999,
|
||||||
subject: const Value('Ghost'),
|
subject: const Value('Ghost'),
|
||||||
receivedAt: DateTime.now(),
|
receivedAt: DateTime.now(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Verify reliability
|
// 2. Verify reliability
|
||||||
final result = await repo.verifySyncReliability('test', 'INBOX');
|
final result = await repo.verifySyncReliability('test', 'INBOX');
|
||||||
expect(result.isHealthy, isFalse);
|
expect(result.isHealthy, isFalse);
|
||||||
expect(result.missingOnServer, contains('test:999'));
|
expect(result.missingOnServer, contains('test:999'));
|
||||||
expect(result.missingLocally, isEmpty);
|
expect(result.missingLocally, isEmpty);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('verifySyncReliability identifies flag mismatches', () async {
|
test('verifySyncReliability identifies flag mismatches', () async {
|
||||||
// 1. Sync one email
|
// 1. Sync one email
|
||||||
@@ -195,8 +193,10 @@ void main() {
|
|||||||
await client.logout();
|
await client.logout();
|
||||||
|
|
||||||
// 2. Need to find the JMAP mailbox ID for INBOX
|
// 2. Need to find the JMAP mailbox ID for INBOX
|
||||||
final mailboxRepo =
|
final mailboxRepo = MailboxRepositoryImpl(
|
||||||
MailboxRepositoryImpl(db, AccountRepositoryImpl(db, secureStorage));
|
db,
|
||||||
|
AccountRepositoryImpl(db, secureStorage),
|
||||||
|
);
|
||||||
await mailboxRepo.syncMailboxes('test-jmap');
|
await mailboxRepo.syncMailboxes('test-jmap');
|
||||||
final mailboxes = await mailboxRepo.observeMailboxes('test-jmap').first;
|
final mailboxes = await mailboxRepo.observeMailboxes('test-jmap').first;
|
||||||
final inbox = mailboxes.firstWhere((m) => m.role == 'inbox');
|
final inbox = mailboxes.firstWhere((m) => m.role == 'inbox');
|
||||||
|
|||||||
@@ -39,36 +39,38 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'returns JmapDiscovery with session URL when well-known/jmap returns 200',
|
'returns JmapDiscovery with session URL when well-known/jmap returns 200',
|
||||||
() async {
|
() async {
|
||||||
final svc = _service({
|
final svc = _service({
|
||||||
'https://example.com/.well-known/jmap': http.Response('{}', 200),
|
'https://example.com/.well-known/jmap': http.Response('{}', 200),
|
||||||
});
|
});
|
||||||
final result = await svc.discover('user@example.com');
|
final result = await svc.discover('user@example.com');
|
||||||
expect(result, isA<JmapDiscovery>());
|
expect(result, isA<JmapDiscovery>());
|
||||||
expect(
|
expect(
|
||||||
(result as JmapDiscovery).sessionUrl,
|
(result as JmapDiscovery).sessionUrl,
|
||||||
'https://example.com/.well-known/jmap',
|
'https://example.com/.well-known/jmap',
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'returns JmapDiscovery with redirect target when well-known/jmap returns 307',
|
'returns JmapDiscovery with redirect target when well-known/jmap returns 307',
|
||||||
() async {
|
() async {
|
||||||
final svc = _service({
|
final svc = _service({
|
||||||
'https://example.com/.well-known/jmap': http.Response(
|
'https://example.com/.well-known/jmap': http.Response(
|
||||||
'',
|
'',
|
||||||
307,
|
307,
|
||||||
headers: {'location': '/jmap/session'},
|
headers: {'location': '/jmap/session'},
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
final result = await svc.discover('user@example.com');
|
final result = await svc.discover('user@example.com');
|
||||||
expect(result, isA<JmapDiscovery>());
|
expect(result, isA<JmapDiscovery>());
|
||||||
expect(
|
expect(
|
||||||
(result as JmapDiscovery).sessionUrl,
|
(result as JmapDiscovery).sessionUrl,
|
||||||
'https://example.com/jmap/session',
|
'https://example.com/jmap/session',
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('returns UnknownDiscovery when well-known/jmap returns 404', () async {
|
test('returns UnknownDiscovery when well-known/jmap returns 404', () async {
|
||||||
final svc = _service({
|
final svc = _service({
|
||||||
@@ -80,8 +82,10 @@ void main() {
|
|||||||
|
|
||||||
test('returns ImapSmtpDiscovery from primary autoconfig URL', () async {
|
test('returns ImapSmtpDiscovery from primary autoconfig URL', () async {
|
||||||
final svc = _service({
|
final svc = _service({
|
||||||
'https://autoconfig.example.com/mail/config-v1.1.xml':
|
'https://autoconfig.example.com/mail/config-v1.1.xml': http.Response(
|
||||||
http.Response(_autoconfigXml, 200),
|
_autoconfigXml,
|
||||||
|
200,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
final result = await svc.discover('user@example.com');
|
final result = await svc.discover('user@example.com');
|
||||||
expect(result, isA<ImapSmtpDiscovery>());
|
expect(result, isA<ImapSmtpDiscovery>());
|
||||||
@@ -94,21 +98,25 @@ void main() {
|
|||||||
expect(imap.smtpSsl, isFalse);
|
expect(imap.smtpSsl, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns ImapSmtpDiscovery from fallback well-known autoconfig URL',
|
test(
|
||||||
() async {
|
'returns ImapSmtpDiscovery from fallback well-known autoconfig URL',
|
||||||
final svc = _service({
|
() async {
|
||||||
'https://example.com/.well-known/autoconfig/mail/config-v1.1.xml':
|
final svc = _service({
|
||||||
http.Response(_autoconfigXml, 200),
|
'https://example.com/.well-known/autoconfig/mail/config-v1.1.xml':
|
||||||
});
|
http.Response(_autoconfigXml, 200),
|
||||||
final result = await svc.discover('user@example.com');
|
});
|
||||||
expect(result, isA<ImapSmtpDiscovery>());
|
final result = await svc.discover('user@example.com');
|
||||||
});
|
expect(result, isA<ImapSmtpDiscovery>());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('prefers JMAP over IMAP when both respond', () async {
|
test('prefers JMAP over IMAP when both respond', () async {
|
||||||
final svc = _service({
|
final svc = _service({
|
||||||
'https://example.com/.well-known/jmap': http.Response('{}', 200),
|
'https://example.com/.well-known/jmap': http.Response('{}', 200),
|
||||||
'https://autoconfig.example.com/mail/config-v1.1.xml':
|
'https://autoconfig.example.com/mail/config-v1.1.xml': http.Response(
|
||||||
http.Response(_autoconfigXml, 200),
|
_autoconfigXml,
|
||||||
|
200,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
final result = await svc.discover('user@example.com');
|
final result = await svc.discover('user@example.com');
|
||||||
expect(result, isA<JmapDiscovery>());
|
expect(result, isA<JmapDiscovery>());
|
||||||
@@ -120,24 +128,26 @@ void main() {
|
|||||||
expect(result, isA<UnknownDiscovery>());
|
expect(result, isA<UnknownDiscovery>());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns ImapSmtpDiscovery from MX record when autoconfig not found',
|
test(
|
||||||
() async {
|
'returns ImapSmtpDiscovery from MX record when autoconfig not found',
|
||||||
final svc = _service({
|
() async {
|
||||||
'https://dns.google/resolve?name=example.com&type=MX': http.Response(
|
final svc = _service({
|
||||||
'{"Status":0,"Answer":[{"type":15,"data":"10 mail.example.com."}]}',
|
'https://dns.google/resolve?name=example.com&type=MX': http.Response(
|
||||||
200,
|
'{"Status":0,"Answer":[{"type":15,"data":"10 mail.example.com."}]}',
|
||||||
),
|
200,
|
||||||
});
|
),
|
||||||
final result = await svc.discover('user@example.com');
|
});
|
||||||
expect(result, isA<ImapSmtpDiscovery>());
|
final result = await svc.discover('user@example.com');
|
||||||
final imap = result as ImapSmtpDiscovery;
|
expect(result, isA<ImapSmtpDiscovery>());
|
||||||
expect(imap.imapHost, 'mail.example.com');
|
final imap = result as ImapSmtpDiscovery;
|
||||||
expect(imap.imapPort, 993);
|
expect(imap.imapHost, 'mail.example.com');
|
||||||
expect(imap.imapSsl, isTrue);
|
expect(imap.imapPort, 993);
|
||||||
expect(imap.smtpHost, 'mail.example.com');
|
expect(imap.imapSsl, isTrue);
|
||||||
expect(imap.smtpPort, 465);
|
expect(imap.smtpHost, 'mail.example.com');
|
||||||
expect(imap.smtpSsl, isTrue);
|
expect(imap.smtpPort, 465);
|
||||||
});
|
expect(imap.smtpSsl, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('MX fallback picks lowest priority record', () async {
|
test('MX fallback picks lowest priority record', () async {
|
||||||
final svc = _service({
|
final svc = _service({
|
||||||
|
|||||||
@@ -82,10 +82,7 @@ void main() {
|
|||||||
|
|
||||||
test('getPassword throws StateError when no password stored', () async {
|
test('getPassword throws StateError when no password stored', () async {
|
||||||
final repo = _makeRepo();
|
final repo = _makeRepo();
|
||||||
expect(
|
expect(() => repo.getPassword('missing'), throwsA(isA<StateError>()));
|
||||||
() => repo.getPassword('missing'),
|
|
||||||
throwsA(isA<StateError>()),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removeAccount deletes account and password', () async {
|
test('removeAccount deletes account and password', () async {
|
||||||
@@ -94,10 +91,7 @@ void main() {
|
|||||||
await repo.removeAccount('acc-1');
|
await repo.removeAccount('acc-1');
|
||||||
|
|
||||||
expect(await repo.getAccount('acc-1'), isNull);
|
expect(await repo.getAccount('acc-1'), isNull);
|
||||||
expect(
|
expect(() => repo.getPassword('acc-1'), throwsA(isA<StateError>()));
|
||||||
() => repo.getPassword('acc-1'),
|
|
||||||
throwsA(isA<StateError>()),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addAccount is idempotent (upsert)', () async {
|
test('addAccount is idempotent (upsert)', () async {
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ ConnectionTestServiceImpl _makeService({
|
|||||||
Exception? imapError,
|
Exception? imapError,
|
||||||
}) {
|
}) {
|
||||||
final mockHttp = MockClient(
|
final mockHttp = MockClient(
|
||||||
(_) async => http.Response(
|
(_) async =>
|
||||||
httpStatus == 200 ? _jmapSessionJson : '',
|
http.Response(httpStatus == 200 ? _jmapSessionJson : '', httpStatus),
|
||||||
httpStatus,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return ConnectionTestServiceImpl(
|
return ConnectionTestServiceImpl(
|
||||||
mockHttp,
|
mockHttp,
|
||||||
@@ -97,10 +95,7 @@ void main() {
|
|||||||
imapConnect: (_, __, ___) async => throw Exception('auth failed'),
|
imapConnect: (_, __, ___) async => throw Exception('auth failed'),
|
||||||
smtpConnect: (_, __, ___) async => FakeSmtpClient(),
|
smtpConnect: (_, __, ___) async => FakeSmtpClient(),
|
||||||
);
|
);
|
||||||
expect(
|
expect(() => svc.testConnection(_imapAccount, 'pw'), throwsException);
|
||||||
() => svc.testConnection(_imapAccount, 'pw'),
|
|
||||||
throwsException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reports SMTP failure after IMAP success', () async {
|
test('reports SMTP failure after IMAP success', () async {
|
||||||
@@ -192,9 +187,7 @@ void main() {
|
|||||||
final svc = _makeService(httpStatus: 500);
|
final svc = _makeService(httpStatus: 500);
|
||||||
expect(
|
expect(
|
||||||
() => svc.testConnection(_jmapAccount, 'pw'),
|
() => svc.testConnection(_jmapAccount, 'pw'),
|
||||||
throwsA(
|
throwsA(predicate((e) => e.toString().contains('Connection failed'))),
|
||||||
predicate((e) => e.toString().contains('Connection failed')),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,19 +8,21 @@ void main() {
|
|||||||
setUpAll(configureSqliteForTests);
|
setUpAll(configureSqliteForTests);
|
||||||
|
|
||||||
group('DraftRepositoryImpl', () {
|
group('DraftRepositoryImpl', () {
|
||||||
test('saveDraft creates a new row and returns it with a non-zero id',
|
test(
|
||||||
() async {
|
'saveDraft creates a new row and returns it with a non-zero id',
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
() async {
|
||||||
final draft = await repo.saveDraft(
|
final repo = DraftRepositoryImpl(openTestDatabase());
|
||||||
toText: 'bob@example.com',
|
final draft = await repo.saveDraft(
|
||||||
ccText: '',
|
toText: 'bob@example.com',
|
||||||
subjectText: 'Hello',
|
ccText: '',
|
||||||
bodyText: 'Hi',
|
subjectText: 'Hello',
|
||||||
);
|
bodyText: 'Hi',
|
||||||
expect(draft.id, isNonZero);
|
);
|
||||||
expect(draft.toText, 'bob@example.com');
|
expect(draft.id, isNonZero);
|
||||||
expect(draft.subjectText, 'Hello');
|
expect(draft.toText, 'bob@example.com');
|
||||||
});
|
expect(draft.subjectText, 'Hello');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('saveDraft with id updates existing row', () async {
|
test('saveDraft with id updates existing row', () async {
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
final repo = DraftRepositoryImpl(openTestDatabase());
|
||||||
@@ -54,48 +56,52 @@ void main() {
|
|||||||
expect(await repo.findDraft(), isNull);
|
expect(await repo.findDraft(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('findDraft returns most recent draft for matching replyToEmailId',
|
test(
|
||||||
() async {
|
'findDraft returns most recent draft for matching replyToEmailId',
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
() async {
|
||||||
await repo.saveDraft(
|
final repo = DraftRepositoryImpl(openTestDatabase());
|
||||||
replyToEmailId: 'email-1',
|
await repo.saveDraft(
|
||||||
toText: 'a@example.com',
|
replyToEmailId: 'email-1',
|
||||||
ccText: '',
|
toText: 'a@example.com',
|
||||||
subjectText: 'Older',
|
ccText: '',
|
||||||
bodyText: '',
|
subjectText: 'Older',
|
||||||
);
|
bodyText: '',
|
||||||
final newer = await repo.saveDraft(
|
);
|
||||||
replyToEmailId: 'email-1',
|
final newer = await repo.saveDraft(
|
||||||
toText: 'a@example.com',
|
replyToEmailId: 'email-1',
|
||||||
ccText: '',
|
toText: 'a@example.com',
|
||||||
subjectText: 'Newer',
|
ccText: '',
|
||||||
bodyText: 'body',
|
subjectText: 'Newer',
|
||||||
);
|
bodyText: 'body',
|
||||||
final found = await repo.findDraft(replyToEmailId: 'email-1');
|
);
|
||||||
expect(found?.id, newer.id);
|
final found = await repo.findDraft(replyToEmailId: 'email-1');
|
||||||
expect(found?.subjectText, 'Newer');
|
expect(found?.id, newer.id);
|
||||||
});
|
expect(found?.subjectText, 'Newer');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('findDraft with null replyToEmailId finds new-message drafts',
|
test(
|
||||||
() async {
|
'findDraft with null replyToEmailId finds new-message drafts',
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
() async {
|
||||||
// This draft is a reply and should NOT be returned.
|
final repo = DraftRepositoryImpl(openTestDatabase());
|
||||||
await repo.saveDraft(
|
// This draft is a reply and should NOT be returned.
|
||||||
replyToEmailId: 'email-1',
|
await repo.saveDraft(
|
||||||
toText: 'x@example.com',
|
replyToEmailId: 'email-1',
|
||||||
ccText: '',
|
toText: 'x@example.com',
|
||||||
subjectText: 'Reply draft',
|
ccText: '',
|
||||||
bodyText: '',
|
subjectText: 'Reply draft',
|
||||||
);
|
bodyText: '',
|
||||||
final newMsg = await repo.saveDraft(
|
);
|
||||||
toText: 'y@example.com',
|
final newMsg = await repo.saveDraft(
|
||||||
ccText: '',
|
toText: 'y@example.com',
|
||||||
subjectText: 'New draft',
|
ccText: '',
|
||||||
bodyText: '',
|
subjectText: 'New draft',
|
||||||
);
|
bodyText: '',
|
||||||
final found = await repo.findDraft();
|
);
|
||||||
expect(found?.id, newMsg.id);
|
final found = await repo.findDraft();
|
||||||
});
|
expect(found?.id, newMsg.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('deleteDraft removes the row', () async {
|
test('deleteDraft removes the row', () async {
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
final repo = DraftRepositoryImpl(openTestDatabase());
|
||||||
|
|||||||
@@ -229,10 +229,16 @@ void main() {
|
|||||||
|
|
||||||
group('SyncEmailsResult', () {
|
group('SyncEmailsResult', () {
|
||||||
test('operator + adds fields', () {
|
test('operator + adds fields', () {
|
||||||
const r1 =
|
const r1 = SyncEmailsResult(
|
||||||
SyncEmailsResult(fetched: 1, skipped: 2, bytesTransferred: 100);
|
fetched: 1,
|
||||||
const r2 =
|
skipped: 2,
|
||||||
SyncEmailsResult(fetched: 3, skipped: 4, bytesTransferred: 200);
|
bytesTransferred: 100,
|
||||||
|
);
|
||||||
|
const r2 = SyncEmailsResult(
|
||||||
|
fetched: 3,
|
||||||
|
skipped: 4,
|
||||||
|
bytesTransferred: 200,
|
||||||
|
);
|
||||||
final r3 = r1 + r2;
|
final r3 = r1 + r2;
|
||||||
expect(r3.fetched, 4);
|
expect(r3.fetched, 4);
|
||||||
expect(r3.skipped, 6);
|
expect(r3.skipped, 6);
|
||||||
|
|||||||
@@ -26,11 +26,7 @@ void main() {
|
|||||||
mockHttpClient = MockClient();
|
mockHttpClient = MockClient();
|
||||||
mockStorage = MockSecureStorage();
|
mockStorage = MockSecureStorage();
|
||||||
final accounts = AccountRepositoryImpl(db, mockStorage);
|
final accounts = AccountRepositoryImpl(db, mockStorage);
|
||||||
repo = EmailRepositoryImpl(
|
repo = EmailRepositoryImpl(db, accounts, httpClient: mockHttpClient);
|
||||||
db,
|
|
||||||
accounts,
|
|
||||||
httpClient: mockHttpClient,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
|
|||||||
@@ -161,11 +161,8 @@ Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
|
|||||||
Future<imap.SmtpClient> _noSmtpConnect(Account a, String u, String p) =>
|
Future<imap.SmtpClient> _noSmtpConnect(Account a, String u, String p) =>
|
||||||
Future.error(UnsupportedError('SMTP unavailable in unit tests'));
|
Future.error(UnsupportedError('SMTP unavailable in unit tests'));
|
||||||
|
|
||||||
({
|
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
|
||||||
AppDatabase db,
|
_makeRepos({http.Client? httpClient}) {
|
||||||
AccountRepositoryImpl accounts,
|
|
||||||
EmailRepositoryImpl emails,
|
|
||||||
}) _makeRepos({http.Client? httpClient}) {
|
|
||||||
final db = openTestDatabase();
|
final db = openTestDatabase();
|
||||||
final storage = MapSecureStorage();
|
final storage = MapSecureStorage();
|
||||||
final accounts = AccountRepositoryImpl(db, storage);
|
final accounts = AccountRepositoryImpl(db, storage);
|
||||||
@@ -421,53 +418,57 @@ void main() {
|
|||||||
|
|
||||||
// ── IMAP method tests ────────────────────────────────────────────────────
|
// ── IMAP method tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('setFlag seen=true enqueues flag_seen change and updates local DB',
|
test(
|
||||||
() async {
|
'setFlag seen=true enqueues flag_seen change and updates local DB',
|
||||||
final r = _makeRepos();
|
() async {
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
final r = _makeRepos();
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
EmailsCompanion.insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
id: 'acc-1:5',
|
EmailsCompanion.insert(
|
||||||
accountId: 'acc-1',
|
id: 'acc-1:5',
|
||||||
mailboxPath: 'INBOX',
|
accountId: 'acc-1',
|
||||||
uid: 5,
|
mailboxPath: 'INBOX',
|
||||||
receivedAt: DateTime(2024),
|
uid: 5,
|
||||||
),
|
receivedAt: DateTime(2024),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await r.emails.setFlag('acc-1:5', seen: true);
|
await r.emails.setFlag('acc-1:5', seen: true);
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes, hasLength(1));
|
expect(changes, hasLength(1));
|
||||||
expect(changes.first.changeType, 'flag_seen');
|
expect(changes.first.changeType, 'flag_seen');
|
||||||
expect(changes.first.payload, contains('"seen":true'));
|
expect(changes.first.payload, contains('"seen":true'));
|
||||||
final email = await r.emails.getEmail('acc-1:5');
|
final email = await r.emails.getEmail('acc-1:5');
|
||||||
expect(email!.isSeen, isTrue);
|
expect(email!.isSeen, isTrue);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('setFlag seen=false enqueues flag_seen change with seen=false',
|
test(
|
||||||
() async {
|
'setFlag seen=false enqueues flag_seen change with seen=false',
|
||||||
final r = _makeRepos();
|
() async {
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
final r = _makeRepos();
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
EmailsCompanion.insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
id: 'acc-1:5',
|
EmailsCompanion.insert(
|
||||||
accountId: 'acc-1',
|
id: 'acc-1:5',
|
||||||
mailboxPath: 'INBOX',
|
accountId: 'acc-1',
|
||||||
uid: 5,
|
mailboxPath: 'INBOX',
|
||||||
receivedAt: DateTime(2024),
|
uid: 5,
|
||||||
isSeen: const Value(true),
|
receivedAt: DateTime(2024),
|
||||||
),
|
isSeen: const Value(true),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await r.emails.setFlag('acc-1:5', seen: false);
|
await r.emails.setFlag('acc-1:5', seen: false);
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes.first.changeType, 'flag_seen');
|
expect(changes.first.changeType, 'flag_seen');
|
||||||
expect(changes.first.payload, contains('"seen":false'));
|
expect(changes.first.payload, contains('"seen":false'));
|
||||||
final email = await r.emails.getEmail('acc-1:5');
|
final email = await r.emails.getEmail('acc-1:5');
|
||||||
expect(email!.isSeen, isFalse);
|
expect(email!.isSeen, isFalse);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('setFlag flagged=true enqueues flag_flagged change', () async {
|
test('setFlag flagged=true enqueues flag_flagged change', () async {
|
||||||
final r = _makeRepos();
|
final r = _makeRepos();
|
||||||
@@ -491,74 +492,78 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'setFlag flagged=false enqueues flag_flagged change with flagged=false',
|
'setFlag flagged=false enqueues flag_flagged change with flagged=false',
|
||||||
() async {
|
() async {
|
||||||
final r = _makeRepos();
|
final r = _makeRepos();
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
id: 'acc-1:5',
|
id: 'acc-1:5',
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
mailboxPath: 'INBOX',
|
mailboxPath: 'INBOX',
|
||||||
uid: 5,
|
uid: 5,
|
||||||
receivedAt: DateTime(2024),
|
receivedAt: DateTime(2024),
|
||||||
isFlagged: const Value(true),
|
isFlagged: const Value(true),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await r.emails.setFlag('acc-1:5', flagged: false);
|
await r.emails.setFlag('acc-1:5', flagged: false);
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes.first.changeType, 'flag_flagged');
|
expect(changes.first.changeType, 'flag_flagged');
|
||||||
expect(changes.first.payload, contains('"flagged":false'));
|
expect(changes.first.payload, contains('"flagged":false'));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'moveEmail enqueues move change and updates local mailboxPath (optimistic)',
|
'moveEmail enqueues move change and updates local mailboxPath (optimistic)',
|
||||||
() async {
|
() async {
|
||||||
final r = _makeRepos();
|
final r = _makeRepos();
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
id: 'acc-1:5',
|
id: 'acc-1:5',
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
mailboxPath: 'INBOX',
|
mailboxPath: 'INBOX',
|
||||||
uid: 5,
|
uid: 5,
|
||||||
receivedAt: DateTime(2024),
|
receivedAt: DateTime(2024),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await r.emails.moveEmail('acc-1:5', 'Archive');
|
await r.emails.moveEmail('acc-1:5', 'Archive');
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes.first.changeType, 'move');
|
expect(changes.first.changeType, 'move');
|
||||||
expect(changes.first.payload, contains('Archive'));
|
expect(changes.first.payload, contains('Archive'));
|
||||||
|
|
||||||
final email = await r.emails.getEmail('acc-1:5');
|
final email = await r.emails.getEmail('acc-1:5');
|
||||||
expect(email, isNotNull);
|
expect(email, isNotNull);
|
||||||
expect(email!.mailboxPath, 'Archive');
|
expect(email!.mailboxPath, 'Archive');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('deleteEmail enqueues delete change and removes email from local DB',
|
test(
|
||||||
() async {
|
'deleteEmail enqueues delete change and removes email from local DB',
|
||||||
final r = _makeRepos();
|
() async {
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
final r = _makeRepos();
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
EmailsCompanion.insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
id: 'acc-1:5',
|
EmailsCompanion.insert(
|
||||||
accountId: 'acc-1',
|
id: 'acc-1:5',
|
||||||
mailboxPath: 'INBOX',
|
accountId: 'acc-1',
|
||||||
uid: 5,
|
mailboxPath: 'INBOX',
|
||||||
receivedAt: DateTime(2024),
|
uid: 5,
|
||||||
),
|
receivedAt: DateTime(2024),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await r.emails.deleteEmail('acc-1:5');
|
await r.emails.deleteEmail('acc-1:5');
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes.first.changeType, 'delete');
|
expect(changes.first.changeType, 'delete');
|
||||||
expect(await r.emails.getEmail('acc-1:5'), isNull);
|
expect(await r.emails.getEmail('acc-1:5'), isNull);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('IMAP flushPendingChanges', () {
|
group('IMAP flushPendingChanges', () {
|
||||||
@@ -729,11 +734,11 @@ void main() {
|
|||||||
'2': {'value': html, 'isTruncated': false},
|
'2': {'value': html, 'isTruncated': false},
|
||||||
},
|
},
|
||||||
'attachments': [],
|
'attachments': [],
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
200,
|
200,
|
||||||
@@ -800,11 +805,11 @@ void main() {
|
|||||||
'htmlBody': [],
|
'htmlBody': [],
|
||||||
'bodyValues': <String, dynamic>{},
|
'bodyValues': <String, dynamic>{},
|
||||||
'attachments': [],
|
'attachments': [],
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
200,
|
200,
|
||||||
@@ -975,10 +980,12 @@ void main() {
|
|||||||
|
|
||||||
final emails = await r.emails.observeEmails('jmap-1', 'mbx1').first;
|
final emails = await r.emails.observeEmails('jmap-1', 'mbx1').first;
|
||||||
expect(emails, hasLength(4));
|
expect(emails, hasLength(4));
|
||||||
expect(
|
expect(emails.map((e) => e.subject).toSet(), {
|
||||||
emails.map((e) => e.subject).toSet(),
|
'Page1-A',
|
||||||
{'Page1-A', 'Page1-B', 'Page2-A', 'Page2-B'},
|
'Page1-B',
|
||||||
);
|
'Page2-A',
|
||||||
|
'Page2-B',
|
||||||
|
});
|
||||||
|
|
||||||
final states = await r.db.select(r.db.syncStates).get();
|
final states = await r.db.select(r.db.syncStates).get();
|
||||||
expect(states.first.state, 'est1');
|
expect(states.first.state, 'est1');
|
||||||
@@ -1002,21 +1009,23 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('setFlag seen enqueues flag_seen change and updates local DB',
|
test(
|
||||||
() async {
|
'setFlag seen enqueues flag_seen change and updates local DB',
|
||||||
final r = _makeRepos();
|
() async {
|
||||||
await seedJmapEmail(r.db, r.accounts);
|
final r = _makeRepos();
|
||||||
|
await seedJmapEmail(r.db, r.accounts);
|
||||||
|
|
||||||
await r.emails.setFlag('jmap-1:e1', seen: true);
|
await r.emails.setFlag('jmap-1:e1', seen: true);
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes, hasLength(1));
|
expect(changes, hasLength(1));
|
||||||
expect(changes.first.changeType, 'flag_seen');
|
expect(changes.first.changeType, 'flag_seen');
|
||||||
expect(changes.first.payload, contains('true'));
|
expect(changes.first.payload, contains('true'));
|
||||||
|
|
||||||
final email = await r.emails.getEmail('jmap-1:e1');
|
final email = await r.emails.getEmail('jmap-1:e1');
|
||||||
expect(email?.isSeen, isTrue);
|
expect(email?.isSeen, isTrue);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('setFlag flagged enqueues flag_flagged change', () async {
|
test('setFlag flagged enqueues flag_flagged change', () async {
|
||||||
final r = _makeRepos();
|
final r = _makeRepos();
|
||||||
@@ -1028,33 +1037,37 @@ void main() {
|
|||||||
expect(changes.first.changeType, 'flag_flagged');
|
expect(changes.first.changeType, 'flag_flagged');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('moveEmail enqueues move change and updates local mailbox path',
|
test(
|
||||||
() async {
|
'moveEmail enqueues move change and updates local mailbox path',
|
||||||
final r = _makeRepos();
|
() async {
|
||||||
await seedJmapEmail(r.db, r.accounts);
|
final r = _makeRepos();
|
||||||
|
await seedJmapEmail(r.db, r.accounts);
|
||||||
|
|
||||||
await r.emails.moveEmail('jmap-1:e1', 'mbx2');
|
await r.emails.moveEmail('jmap-1:e1', 'mbx2');
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes.first.changeType, 'move');
|
expect(changes.first.changeType, 'move');
|
||||||
expect(changes.first.payload, contains('mbx2'));
|
expect(changes.first.payload, contains('mbx2'));
|
||||||
|
|
||||||
final email = await r.emails.getEmail('jmap-1:e1');
|
final email = await r.emails.getEmail('jmap-1:e1');
|
||||||
expect(email, isNotNull);
|
expect(email, isNotNull);
|
||||||
expect(email?.mailboxPath, 'mbx2');
|
expect(email?.mailboxPath, 'mbx2');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('deleteEmail enqueues delete change and removes email from local DB',
|
test(
|
||||||
() async {
|
'deleteEmail enqueues delete change and removes email from local DB',
|
||||||
final r = _makeRepos();
|
() async {
|
||||||
await seedJmapEmail(r.db, r.accounts);
|
final r = _makeRepos();
|
||||||
|
await seedJmapEmail(r.db, r.accounts);
|
||||||
|
|
||||||
await r.emails.deleteEmail('jmap-1:e1');
|
await r.emails.deleteEmail('jmap-1:e1');
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes.first.changeType, 'delete');
|
expect(changes.first.changeType, 'delete');
|
||||||
expect(await r.emails.getEmail('jmap-1:e1'), isNull);
|
expect(await r.emails.getEmail('jmap-1:e1'), isNull);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('JMAP flushPendingChanges', () {
|
group('JMAP flushPendingChanges', () {
|
||||||
@@ -1246,130 +1259,134 @@ void main() {
|
|||||||
expect(states.first.state, 'est2');
|
expect(states.first.state, 'est2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stateMismatch clears sync state and marks change as failed',
|
test(
|
||||||
() async {
|
'stateMismatch clears sync state and marks change as failed',
|
||||||
final client = MockClient((req) async {
|
() async {
|
||||||
if (req.url.path.contains('well-known')) {
|
final client = MockClient((req) async {
|
||||||
return http.Response(
|
if (req.url.path.contains('well-known')) {
|
||||||
jsonEncode({
|
return http.Response(
|
||||||
'apiUrl': 'https://jmap.example.com/api/',
|
jsonEncode({
|
||||||
'accounts': {'acct1': {}},
|
'apiUrl': 'https://jmap.example.com/api/',
|
||||||
'primaryAccounts': {
|
'accounts': {'acct1': {}},
|
||||||
'urn:ietf:params:jmap:core': 'acct1',
|
'primaryAccounts': {
|
||||||
'urn:ietf:params:jmap:mail': 'acct1',
|
'urn:ietf:params:jmap:core': 'acct1',
|
||||||
},
|
'urn:ietf:params:jmap:mail': 'acct1',
|
||||||
'capabilities': {},
|
|
||||||
'username': 'alice@example.com',
|
|
||||||
'state': 'sess1',
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Server responds with stateMismatch error inside Email/set
|
|
||||||
return http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'sessionState': 's1',
|
|
||||||
'methodResponses': [
|
|
||||||
[
|
|
||||||
'Email/set',
|
|
||||||
{'accountId': 'acct1', 'type': 'stateMismatch'},
|
|
||||||
'0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final r = _makeRepos(httpClient: client);
|
|
||||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
|
||||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
|
||||||
SyncStatesCompanion.insert(
|
|
||||||
accountId: 'jmap-1',
|
|
||||||
resourceType: 'Email',
|
|
||||||
state: 'est1',
|
|
||||||
syncedAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await r.db.into(r.db.pendingChanges).insert(
|
|
||||||
PendingChangesCompanion.insert(
|
|
||||||
accountId: 'jmap-1',
|
|
||||||
resourceType: 'Email',
|
|
||||||
resourceId: 'jmap-1:e1',
|
|
||||||
changeType: 'flag_seen',
|
|
||||||
payload: '{"seen":true}',
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
|
||||||
|
|
||||||
// Sync state should be cleared so next cycle does a full re-sync
|
|
||||||
expect(await r.db.select(r.db.syncStates).get(), isEmpty);
|
|
||||||
|
|
||||||
// Change should still be present but with attempt count bumped
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
|
||||||
expect(changes.first.attempts, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('discards change immediately on notUpdated (permanent error)',
|
|
||||||
() async {
|
|
||||||
final client = MockClient((req) async {
|
|
||||||
if (req.url.path.contains('well-known')) {
|
|
||||||
return http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'apiUrl': 'https://jmap.example.com/api/',
|
|
||||||
'accounts': {'acct1': {}},
|
|
||||||
'primaryAccounts': {
|
|
||||||
'urn:ietf:params:jmap:core': 'acct1',
|
|
||||||
'urn:ietf:params:jmap:mail': 'acct1',
|
|
||||||
},
|
|
||||||
'capabilities': {},
|
|
||||||
'username': 'alice@example.com',
|
|
||||||
'state': 'sess1',
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Server responds with notUpdated — permanent per-item error
|
|
||||||
return http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'sessionState': 's1',
|
|
||||||
'methodResponses': [
|
|
||||||
[
|
|
||||||
'Email/set',
|
|
||||||
{
|
|
||||||
'accountId': 'acct1',
|
|
||||||
'notUpdated': {
|
|
||||||
'e1': {'type': 'notFound'},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'0',
|
'capabilities': {},
|
||||||
|
'username': 'alice@example.com',
|
||||||
|
'state': 'sess1',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Server responds with stateMismatch error inside Email/set
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'sessionState': 's1',
|
||||||
|
'methodResponses': [
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{'accountId': 'acct1', 'type': 'stateMismatch'},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
}),
|
||||||
}),
|
200,
|
||||||
200,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final r = _makeRepos(httpClient: client);
|
|
||||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
|
||||||
await r.db.into(r.db.pendingChanges).insert(
|
|
||||||
PendingChangesCompanion.insert(
|
|
||||||
accountId: 'jmap-1',
|
|
||||||
resourceType: 'Email',
|
|
||||||
resourceId: 'jmap-1:e1',
|
|
||||||
changeType: 'flag_seen',
|
|
||||||
payload: '{"seen":true}',
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
final r = _makeRepos(httpClient: client);
|
||||||
|
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||||
|
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||||
|
SyncStatesCompanion.insert(
|
||||||
|
accountId: 'jmap-1',
|
||||||
|
resourceType: 'Email',
|
||||||
|
state: 'est1',
|
||||||
|
syncedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.pendingChanges).insert(
|
||||||
|
PendingChangesCompanion.insert(
|
||||||
|
accountId: 'jmap-1',
|
||||||
|
resourceType: 'Email',
|
||||||
|
resourceId: 'jmap-1:e1',
|
||||||
|
changeType: 'flag_seen',
|
||||||
|
payload: '{"seen":true}',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Permanent error — change is immediately evicted
|
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
|
||||||
});
|
// Sync state should be cleared so next cycle does a full re-sync
|
||||||
|
expect(await r.db.select(r.db.syncStates).get(), isEmpty);
|
||||||
|
|
||||||
|
// Change should still be present but with attempt count bumped
|
||||||
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
|
expect(changes.first.attempts, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'discards change immediately on notUpdated (permanent error)',
|
||||||
|
() async {
|
||||||
|
final client = MockClient((req) async {
|
||||||
|
if (req.url.path.contains('well-known')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'apiUrl': 'https://jmap.example.com/api/',
|
||||||
|
'accounts': {'acct1': {}},
|
||||||
|
'primaryAccounts': {
|
||||||
|
'urn:ietf:params:jmap:core': 'acct1',
|
||||||
|
'urn:ietf:params:jmap:mail': 'acct1',
|
||||||
|
},
|
||||||
|
'capabilities': {},
|
||||||
|
'username': 'alice@example.com',
|
||||||
|
'state': 'sess1',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Server responds with notUpdated — permanent per-item error
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'sessionState': 's1',
|
||||||
|
'methodResponses': [
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
'accountId': 'acct1',
|
||||||
|
'notUpdated': {
|
||||||
|
'e1': {'type': 'notFound'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final r = _makeRepos(httpClient: client);
|
||||||
|
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||||
|
await r.db.into(r.db.pendingChanges).insert(
|
||||||
|
PendingChangesCompanion.insert(
|
||||||
|
accountId: 'jmap-1',
|
||||||
|
resourceType: 'Email',
|
||||||
|
resourceId: 'jmap-1:e1',
|
||||||
|
changeType: 'flag_seen',
|
||||||
|
payload: '{"seen":true}',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
||||||
|
|
||||||
|
// Permanent error — change is immediately evicted
|
||||||
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('evicts change after max attempts (5)', () async {
|
test('evicts change after max attempts (5)', () async {
|
||||||
final r = _makeRepos(httpClient: mockFlush(500));
|
final r = _makeRepos(httpClient: mockFlush(500));
|
||||||
@@ -1459,9 +1476,7 @@ void main() {
|
|||||||
apiResponses: [
|
apiResponses: [
|
||||||
_emailGetResponse(
|
_emailGetResponse(
|
||||||
state: 'est1',
|
state: 'est1',
|
||||||
list: [
|
list: [_jmapEmail(id: 'e1', mailboxId: 'mbx1')],
|
||||||
_jmapEmail(id: 'e1', mailboxId: 'mbx1'),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,13 +43,7 @@ http.Client _sessionClient({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode(
|
jsonEncode(apiBody ?? {'sessionState': 'st1', 'methodResponses': []}),
|
||||||
apiBody ??
|
|
||||||
{
|
|
||||||
'sessionState': 'st1',
|
|
||||||
'methodResponses': [],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
apiStatus,
|
apiStatus,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -137,10 +131,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('JmapClient.call', () {
|
group('JmapClient.call', () {
|
||||||
Future<JmapClient> connected({
|
Future<JmapClient> connected({int apiStatus = 200, dynamic apiBody}) =>
|
||||||
int apiStatus = 200,
|
|
||||||
dynamic apiBody,
|
|
||||||
}) =>
|
|
||||||
JmapClient.connect(
|
JmapClient.connect(
|
||||||
httpClient: _sessionClient(apiStatus: apiStatus, apiBody: apiBody),
|
httpClient: _sessionClient(apiStatus: apiStatus, apiBody: apiBody),
|
||||||
jmapUrl: Uri.parse(_sessionUrl),
|
jmapUrl: Uri.parse(_sessionUrl),
|
||||||
@@ -154,7 +145,7 @@ void main() {
|
|||||||
'Mailbox/get',
|
'Mailbox/get',
|
||||||
<String, dynamic>{'state': 'st2', 'list': []},
|
<String, dynamic>{'state': 'st2', 'list': []},
|
||||||
'0',
|
'0',
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
final client = await connected(
|
final client = await connected(
|
||||||
apiBody: {'sessionState': 'st1', 'methodResponses': responses},
|
apiBody: {'sessionState': 'st1', 'methodResponses': responses},
|
||||||
@@ -164,7 +155,7 @@ void main() {
|
|||||||
'Mailbox/get',
|
'Mailbox/get',
|
||||||
{'accountId': _accountId, 'ids': null},
|
{'accountId': _accountId, 'ids': null},
|
||||||
'0',
|
'0',
|
||||||
]
|
],
|
||||||
]);
|
]);
|
||||||
expect(result, hasLength(1));
|
expect(result, hasLength(1));
|
||||||
expect((result[0] as List<dynamic>)[0], 'Mailbox/get');
|
expect((result[0] as List<dynamic>)[0], 'Mailbox/get');
|
||||||
@@ -178,7 +169,7 @@ void main() {
|
|||||||
'Mailbox/get',
|
'Mailbox/get',
|
||||||
{'accountId': _accountId},
|
{'accountId': _accountId},
|
||||||
'0',
|
'0',
|
||||||
]
|
],
|
||||||
]),
|
]),
|
||||||
throwsA(isA<JmapException>()),
|
throwsA(isA<JmapException>()),
|
||||||
);
|
);
|
||||||
@@ -194,7 +185,7 @@ void main() {
|
|||||||
'Mailbox/get',
|
'Mailbox/get',
|
||||||
{'accountId': _accountId},
|
{'accountId': _accountId},
|
||||||
'0',
|
'0',
|
||||||
]
|
],
|
||||||
]),
|
]),
|
||||||
throwsA(isA<JmapException>()),
|
throwsA(isA<JmapException>()),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -130,10 +130,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('copyWith works', () {
|
test('copyWith works', () {
|
||||||
final updated = mailbox.copyWith(
|
final updated = mailbox.copyWith(unreadCount: 5, role: 'inbox');
|
||||||
unreadCount: 5,
|
|
||||||
role: 'inbox',
|
|
||||||
);
|
|
||||||
expect(updated.unreadCount, 5);
|
expect(updated.unreadCount, 5);
|
||||||
expect(updated.role, 'inbox');
|
expect(updated.role, 'inbox');
|
||||||
expect(updated.id, mailbox.id);
|
expect(updated.id, mailbox.id);
|
||||||
|
|||||||
@@ -155,47 +155,50 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
|
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
|
||||||
expect(
|
expect(mailboxes.map((m) => m.path).toList(), [
|
||||||
mailboxes.map((m) => m.path).toList(),
|
'Drafts',
|
||||||
['Drafts', 'INBOX', 'Sent'],
|
'INBOX',
|
||||||
);
|
'Sent',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('observeMailboxes only returns mailboxes for the given account',
|
test(
|
||||||
() async {
|
'observeMailboxes only returns mailboxes for the given account',
|
||||||
final r = _makeRepos();
|
() async {
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
const other = Account(
|
const other = Account(
|
||||||
id: 'acc-2',
|
id: 'acc-2',
|
||||||
displayName: 'Bob',
|
displayName: 'Bob',
|
||||||
email: 'bob@example.com',
|
email: 'bob@example.com',
|
||||||
imapHost: 'imap.example.com',
|
imapHost: 'imap.example.com',
|
||||||
smtpHost: 'smtp.example.com',
|
smtpHost: 'smtp.example.com',
|
||||||
);
|
);
|
||||||
await r.accounts.addAccount(other, 'pw2');
|
await r.accounts.addAccount(other, 'pw2');
|
||||||
|
|
||||||
await r.db.into(r.db.mailboxes).insert(
|
await r.db.into(r.db.mailboxes).insert(
|
||||||
MailboxesCompanion.insert(
|
MailboxesCompanion.insert(
|
||||||
id: 'acc-1:INBOX',
|
id: 'acc-1:INBOX',
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
path: 'INBOX',
|
path: 'INBOX',
|
||||||
name: 'Inbox',
|
name: 'Inbox',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await r.db.into(r.db.mailboxes).insert(
|
await r.db.into(r.db.mailboxes).insert(
|
||||||
MailboxesCompanion.insert(
|
MailboxesCompanion.insert(
|
||||||
id: 'acc-2:INBOX',
|
id: 'acc-2:INBOX',
|
||||||
accountId: 'acc-2',
|
accountId: 'acc-2',
|
||||||
path: 'INBOX',
|
path: 'INBOX',
|
||||||
name: 'Inbox',
|
name: 'Inbox',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
|
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
|
||||||
expect(mailboxes, hasLength(1));
|
expect(mailboxes, hasLength(1));
|
||||||
expect(mailboxes.first.id, 'acc-1:INBOX');
|
expect(mailboxes.first.id, 'acc-1:INBOX');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('observeMailboxes maps unread/total counts', () async {
|
test('observeMailboxes maps unread/total counts', () async {
|
||||||
final r = _makeRepos();
|
final r = _makeRepos();
|
||||||
@@ -377,30 +380,32 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('syncMailboxes throws JmapException on API error response',
|
test(
|
||||||
() async {
|
'syncMailboxes throws JmapException on API error response',
|
||||||
final r = _makeRepos(
|
() async {
|
||||||
httpClient: _mockJmap(
|
final r = _makeRepos(
|
||||||
apiResponses: [
|
httpClient: _mockJmap(
|
||||||
{
|
apiResponses: [
|
||||||
'sessionState': 'sess1',
|
{
|
||||||
'methodResponses': [
|
'sessionState': 'sess1',
|
||||||
[
|
'methodResponses': [
|
||||||
'error',
|
[
|
||||||
<String, dynamic>{'type': 'serverFail'},
|
'error',
|
||||||
'0',
|
<String, dynamic>{'type': 'serverFail'},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
await expectLater(
|
||||||
await expectLater(
|
r.mailboxes.syncMailboxes('jmap-1'),
|
||||||
r.mailboxes.syncMailboxes('jmap-1'),
|
throwsA(isA<JmapException>()),
|
||||||
throwsA(isA<JmapException>()),
|
);
|
||||||
);
|
},
|
||||||
});
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('findMailboxByRole returns null when no matching mailbox', () async {
|
test('findMailboxByRole returns null when no matching mailbox', () async {
|
||||||
|
|||||||
@@ -75,8 +75,9 @@ void main() {
|
|||||||
finishedAt: end,
|
finishedAt: end,
|
||||||
);
|
);
|
||||||
|
|
||||||
final rows = await (db.select(db.syncLogs)
|
final rows = await (db.select(
|
||||||
..where((r) => r.result.equals('error')))
|
db.syncLogs,
|
||||||
|
)..where((r) => r.result.equals('error')))
|
||||||
.get();
|
.get();
|
||||||
expect(rows, hasLength(1));
|
expect(rows, hasLength(1));
|
||||||
expect(rows.first.result, 'error');
|
expect(rows.first.result, 'error');
|
||||||
|
|||||||
@@ -205,50 +205,51 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Undo deletion for IMAP enqueues reverse move if cancel fails',
|
test(
|
||||||
() async {
|
'Undo deletion for IMAP enqueues reverse move if cancel fails',
|
||||||
const emailId = 'acc1:101';
|
() async {
|
||||||
final original = await repo.getEmail(emailId);
|
const emailId = 'acc1:101';
|
||||||
|
final original = await repo.getEmail(emailId);
|
||||||
|
|
||||||
// 1. Delete
|
// 1. Delete
|
||||||
final destPath = await repo.deleteEmail(emailId);
|
final destPath = await repo.deleteEmail(emailId);
|
||||||
expect(destPath, 'Trash');
|
expect(destPath, 'Trash');
|
||||||
|
|
||||||
// 2. Mark the pending change as "attempted" so it cannot be cancelled
|
// 2. Mark the pending change as "attempted" so it cannot be cancelled
|
||||||
await (db.update(db.pendingChanges)
|
await (db.update(db.pendingChanges)
|
||||||
..where((t) => t.resourceId.equals(emailId)))
|
..where((t) => t.resourceId.equals(emailId)))
|
||||||
.write(
|
.write(const PendingChangesCompanion(attempts: Value(1)));
|
||||||
const PendingChangesCompanion(attempts: Value(1)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Undo
|
// 3. Undo
|
||||||
final action = UndoAction(
|
final action = UndoAction(
|
||||||
id: 'undo3',
|
id: 'undo3',
|
||||||
accountId: 'acc1',
|
accountId: 'acc1',
|
||||||
type: UndoType.delete,
|
type: UndoType.delete,
|
||||||
emailIds: [emailId],
|
emailIds: [emailId],
|
||||||
sourceMailboxPath: 'INBOX',
|
sourceMailboxPath: 'INBOX',
|
||||||
destinationMailboxPath: destPath,
|
destinationMailboxPath: destPath,
|
||||||
originalEmails: [original!],
|
originalEmails: [original!],
|
||||||
);
|
);
|
||||||
container.read(undoServiceProvider.notifier).pushAction(action);
|
container.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
await container.read(undoServiceProvider.notifier).undo();
|
await container.read(undoServiceProvider.notifier).undo();
|
||||||
|
|
||||||
// 4. Verify local state
|
// 4. Verify local state
|
||||||
final restored = await (db.select(db.emails)
|
final restored = await (db.select(db.emails)
|
||||||
..where((t) => t.id.equals(emailId))
|
..where((t) => t.id.equals(emailId))
|
||||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||||
.get();
|
.get();
|
||||||
expect(restored, isNotEmpty);
|
expect(restored, isNotEmpty);
|
||||||
|
|
||||||
// 5. Verify a NEW pending change was enqueued (Trash -> INBOX)
|
// 5. Verify a NEW pending change was enqueued (Trash -> INBOX)
|
||||||
final changes = await db.select(db.pendingChanges).get();
|
final changes = await db.select(db.pendingChanges).get();
|
||||||
final reverseMove =
|
final reverseMove = changes.firstWhere(
|
||||||
changes.firstWhere((c) => c.changeType == 'move' && c.attempts == 0);
|
(c) => c.changeType == 'move' && c.attempts == 0,
|
||||||
final payload = jsonDecode(reverseMove.payload) as Map<String, dynamic>;
|
);
|
||||||
expect(payload['mailboxPath'], 'Trash');
|
final payload = jsonDecode(reverseMove.payload) as Map<String, dynamic>;
|
||||||
expect(payload['dest'], 'INBOX');
|
expect(payload['mailboxPath'], 'Trash');
|
||||||
});
|
expect(payload['dest'], 'INBOX');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('Undo snooze clears snooze metadata and moves back', () async {
|
test('Undo snooze clears snooze metadata and moves back', () async {
|
||||||
const emailId = 'acc1:101';
|
const emailId = 'acc1:101';
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ void main() {
|
|||||||
|
|
||||||
when(mockUndoRepo.saveAction(any)).thenAnswer((_) async {});
|
when(mockUndoRepo.saveAction(any)).thenAnswer((_) async {});
|
||||||
when(mockUndoRepo.deleteAction(any)).thenAnswer((_) async {});
|
when(mockUndoRepo.deleteAction(any)).thenAnswer((_) async {});
|
||||||
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
|
when(
|
||||||
.thenAnswer((_) async => []);
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||||
|
).thenAnswer((_) async => []);
|
||||||
|
|
||||||
container = ProviderContainer(
|
container = ProviderContainer(
|
||||||
overrides: [
|
overrides: [
|
||||||
@@ -84,8 +85,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||||
when(mockEmailRepo.cancelPendingChange(any, any))
|
when(
|
||||||
.thenAnswer((_) async => false);
|
mockEmailRepo.cancelPendingChange(any, any),
|
||||||
|
).thenAnswer((_) async => false);
|
||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
@@ -118,8 +120,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||||
when(mockEmailRepo.cancelPendingChange(any, any))
|
when(
|
||||||
.thenAnswer((_) async => false);
|
mockEmailRepo.cancelPendingChange(any, any),
|
||||||
|
).thenAnswer((_) async => false);
|
||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
@@ -142,10 +145,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||||
when(mockEmailRepo.cancelPendingChange('e1', 'delete'))
|
when(
|
||||||
.thenAnswer((_) async => false);
|
mockEmailRepo.cancelPendingChange('e1', 'delete'),
|
||||||
when(mockEmailRepo.cancelPendingChange('e1', 'move'))
|
).thenAnswer((_) async => false);
|
||||||
.thenAnswer((_) async => true);
|
when(
|
||||||
|
mockEmailRepo.cancelPendingChange('e1', 'move'),
|
||||||
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
@@ -180,8 +185,9 @@ void main() {
|
|||||||
originalEmails: [email],
|
originalEmails: [email],
|
||||||
);
|
);
|
||||||
|
|
||||||
when(mockEmailRepo.cancelPendingChange(any, any))
|
when(
|
||||||
.thenAnswer((_) async => false);
|
mockEmailRepo.cancelPendingChange(any, any),
|
||||||
|
).thenAnswer((_) async => false);
|
||||||
when(mockEmailRepo.restoreEmails(any)).thenAnswer((_) async {});
|
when(mockEmailRepo.restoreEmails(any)).thenAnswer((_) async {});
|
||||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import 'helpers.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('AccountListScreen', () {
|
group('AccountListScreen', () {
|
||||||
testWidgets('shows "No accounts yet." when repository is empty',
|
testWidgets('shows "No accounts yet." when repository is empty', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(initialLocation: '/accounts', overrides: baseOverrides()),
|
buildApp(initialLocation: '/accounts', overrides: baseOverrides()),
|
||||||
);
|
);
|
||||||
@@ -16,8 +17,9 @@ void main() {
|
|||||||
expect(find.text('Add account'), findsOneWidget);
|
expect(find.text('Add account'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows account tile when repository has an account',
|
testWidgets('shows account tile when repository has an account', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts',
|
initialLocation: '/accounts',
|
||||||
@@ -44,8 +46,9 @@ void main() {
|
|||||||
expect(find.textContaining('IMAP'), findsOneWidget);
|
expect(find.textContaining('IMAP'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows check icon after successful connection test',
|
testWidgets('shows check icon after successful connection test', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts',
|
initialLocation: '/accounts',
|
||||||
@@ -87,21 +90,23 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'"Add account" button in empty state navigates to add-account screen',
|
'"Add account" button in empty state navigates to add-account screen',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(initialLocation: '/accounts', overrides: baseOverrides()),
|
buildApp(initialLocation: '/accounts', overrides: baseOverrides()),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Add account'));
|
await tester.tap(find.text('Add account'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Email address'), findsOneWidget);
|
expect(find.text('Email address'), findsOneWidget);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('tapping an account tile navigates to its mailboxes',
|
testWidgets('tapping an account tile navigates to its mailboxes', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts',
|
initialLocation: '/accounts',
|
||||||
@@ -131,8 +136,9 @@ void main() {
|
|||||||
expect(find.text('Add account'), findsOneWidget);
|
expect(find.text('Add account'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('AppBar does not overflow at minimum supported window size',
|
testWidgets('AppBar does not overflow at minimum supported window size', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
tester.view.physicalSize = const Size(400, 300);
|
tester.view.physicalSize = const Size(400, 300);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import 'helpers.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('AddAccountScreen', () {
|
group('AddAccountScreen', () {
|
||||||
testWidgets('step 1: shows email field and Continue button',
|
testWidgets('step 1: shows email field and Continue button', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()),
|
||||||
initialLocation: '/accounts/add',
|
|
||||||
overrides: baseOverrides(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -24,10 +22,7 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('step 1: empty submit shows validation error', (tester) async {
|
testWidgets('step 1: empty submit shows validation error', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()),
|
||||||
initialLocation: '/accounts/add',
|
|
||||||
overrides: baseOverrides(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -39,10 +34,7 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('step 1: invalid email shows validation error', (tester) async {
|
testWidgets('step 1: invalid email shows validation error', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()),
|
||||||
initialLocation: '/accounts/add',
|
|
||||||
overrides: baseOverrides(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -73,14 +65,16 @@ void main() {
|
|||||||
expect(find.text('IMAP / SMTP'), findsOneWidget);
|
expect(find.text('IMAP / SMTP'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('JMAP discovery navigates directly to JMAP form',
|
testWidgets('JMAP discovery navigates directly to JMAP form', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/add',
|
initialLocation: '/accounts/add',
|
||||||
overrides: baseOverrides(
|
overrides: baseOverrides(
|
||||||
discovery:
|
discovery: JmapDiscovery(
|
||||||
JmapDiscovery(sessionUrl: 'https://mail.example.com/jmap'),
|
sessionUrl: 'https://mail.example.com/jmap',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -97,8 +91,9 @@ void main() {
|
|||||||
expect(find.text('https://mail.example.com/jmap'), findsOneWidget);
|
expect(find.text('https://mail.example.com/jmap'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('IMAP discovery navigates directly to IMAP form',
|
testWidgets('IMAP discovery navigates directly to IMAP form', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/add',
|
initialLocation: '/accounts/add',
|
||||||
@@ -150,8 +145,9 @@ void main() {
|
|||||||
expect(find.text('JMAP API URL'), findsOneWidget);
|
expect(find.text('JMAP API URL'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('choose-type: tapping IMAP/SMTP shows IMAP form',
|
testWidgets('choose-type: tapping IMAP/SMTP shows IMAP form', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/add',
|
initialLocation: '/accounts/add',
|
||||||
@@ -174,14 +170,16 @@ void main() {
|
|||||||
expect(find.text('SMTP'), findsOneWidget);
|
expect(find.text('SMTP'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('successful JMAP save pops back to accounts list',
|
testWidgets('successful JMAP save pops back to accounts list', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/add',
|
initialLocation: '/accounts/add',
|
||||||
overrides: baseOverrides(
|
overrides: baseOverrides(
|
||||||
discovery:
|
discovery: JmapDiscovery(
|
||||||
JmapDiscovery(sessionUrl: 'https://mail.example.com/jmap'),
|
sessionUrl: 'https://mail.example.com/jmap',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -213,8 +211,9 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/add',
|
initialLocation: '/accounts/add',
|
||||||
overrides: baseOverrides(
|
overrides: baseOverrides(
|
||||||
discovery:
|
discovery: JmapDiscovery(
|
||||||
JmapDiscovery(sessionUrl: 'https://mail.example.com/jmap'),
|
sessionUrl: 'https://mail.example.com/jmap',
|
||||||
|
),
|
||||||
connectionError: Exception('auth failed'),
|
connectionError: Exception('auth failed'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -242,8 +241,9 @@ void main() {
|
|||||||
expect(find.textContaining('Connection failed'), findsOneWidget);
|
expect(find.textContaining('Connection failed'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('successful IMAP save pops back to accounts list',
|
testWidgets('successful IMAP save pops back to accounts list', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
@@ -288,45 +288,46 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'IMAP form hides SSL toggle for non-localhost, shows for localhost',
|
'IMAP form hides SSL toggle for non-localhost, shows for localhost',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/add',
|
initialLocation: '/accounts/add',
|
||||||
overrides: baseOverrides(discovery: UnknownDiscovery()),
|
overrides: baseOverrides(discovery: UnknownDiscovery()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const Key('emailField')),
|
find.byKey(const Key('emailField')),
|
||||||
'user@example.com',
|
'user@example.com',
|
||||||
);
|
);
|
||||||
await tester.tap(find.text('Continue'));
|
await tester.tap(find.text('Continue'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('IMAP / SMTP'));
|
await tester.tap(find.text('IMAP / SMTP'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('IMAP'), findsOneWidget);
|
expect(find.text('IMAP'), findsOneWidget);
|
||||||
// No SSL toggles shown when hosts are empty (non-localhost).
|
// No SSL toggles shown when hosts are empty (non-localhost).
|
||||||
expect(find.byType(SwitchListTile), findsNothing);
|
expect(find.byType(SwitchListTile), findsNothing);
|
||||||
|
|
||||||
// Entering localhost as IMAP host reveals the IMAP SSL toggle.
|
// Entering localhost as IMAP host reveals the IMAP SSL toggle.
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextFormField, 'Host').first,
|
find.widgetWithText(TextFormField, 'Host').first,
|
||||||
'localhost',
|
'localhost',
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byType(SwitchListTile), findsOneWidget);
|
expect(find.byType(SwitchListTile), findsOneWidget);
|
||||||
|
|
||||||
// Entering localhost as SMTP host reveals both SSL toggles.
|
// Entering localhost as SMTP host reveals both SSL toggles.
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextFormField, 'Host').last,
|
find.widgetWithText(TextFormField, 'Host').last,
|
||||||
'localhost',
|
'localhost',
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byType(SwitchListTile), findsNWidgets(2));
|
expect(find.byType(SwitchListTile), findsNWidgets(2));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/compose',
|
initialLocation: '/compose',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
],
|
],
|
||||||
@@ -33,8 +35,9 @@ void main() {
|
|||||||
expect(find.text('Body'), findsOneWidget);
|
expect(find.text('Body'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('prefills To and Subject when provided as constructor params',
|
testWidgets('prefills To and Subject when provided as constructor params', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
_buildDirect(
|
_buildDirect(
|
||||||
screen: const ComposeScreen(
|
screen: const ComposeScreen(
|
||||||
@@ -42,10 +45,12 @@ void main() {
|
|||||||
prefillSubject: 'Re: Hello',
|
prefillSubject: 'Re: Hello',
|
||||||
),
|
),
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
],
|
],
|
||||||
@@ -60,16 +65,19 @@ void main() {
|
|||||||
expect(find.widgetWithText(TextFormField, 'Re: Hello'), findsOneWidget);
|
expect(find.widgetWithText(TextFormField, 'Re: Hello'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows static From field when one account is loaded',
|
testWidgets('shows static From field when one account is loaded', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/compose',
|
initialLocation: '/compose',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
],
|
],
|
||||||
@@ -80,8 +88,9 @@ void main() {
|
|||||||
expect(find.text('Alice <alice@example.com>'), findsOneWidget);
|
expect(find.text('Alice <alice@example.com>'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows From dropdown when multiple accounts are loaded',
|
testWidgets('shows From dropdown when multiple accounts are loaded', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
const second = Account(
|
const second = Account(
|
||||||
id: 'acc-2',
|
id: 'acc-2',
|
||||||
displayName: 'Bob',
|
displayName: 'Bob',
|
||||||
@@ -96,8 +105,9 @@ void main() {
|
|||||||
accountRepositoryProvider.overrideWithValue(
|
accountRepositoryProvider.overrideWithValue(
|
||||||
FakeAccountRepository([kTestAccount, second]),
|
FakeAccountRepository([kTestAccount, second]),
|
||||||
),
|
),
|
||||||
mailboxRepositoryProvider
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
],
|
],
|
||||||
@@ -108,8 +118,9 @@ void main() {
|
|||||||
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
|
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('restores saved draft when no prefill is provided',
|
testWidgets('restores saved draft when no prefill is provided', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
final fakeDrafts = FakeDraftRepository();
|
final fakeDrafts = FakeDraftRepository();
|
||||||
await fakeDrafts.saveDraft(
|
await fakeDrafts.saveDraft(
|
||||||
toText: 'carol@example.com',
|
toText: 'carol@example.com',
|
||||||
@@ -121,10 +132,12 @@ void main() {
|
|||||||
_buildDirect(
|
_buildDirect(
|
||||||
screen: const ComposeScreen(),
|
screen: const ComposeScreen(),
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
draftRepositoryProvider.overrideWithValue(fakeDrafts),
|
draftRepositoryProvider.overrideWithValue(fakeDrafts),
|
||||||
],
|
],
|
||||||
@@ -152,9 +165,7 @@ Widget _buildDirect({
|
|||||||
}) {
|
}) {
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/',
|
initialLocation: '/',
|
||||||
routes: [
|
routes: [GoRoute(path: '/', builder: (ctx, state) => screen)],
|
||||||
GoRoute(path: '/', builder: (ctx, state) => screen),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: overrides,
|
overrides: overrides,
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ class MockUrlLauncher extends Mock
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('CrashScreen shows error details and has a report button',
|
testWidgets('CrashScreen shows error details and has a report button', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
tester.view.physicalSize = const Size(800, 1200);
|
tester.view.physicalSize = const Size(800, 1200);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(() => tester.view.resetPhysicalSize());
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import 'helpers.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('EditAccountScreen', () {
|
group('EditAccountScreen', () {
|
||||||
testWidgets('shows account email and type label after loading',
|
testWidgets('shows account email and type label after loading', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/edit',
|
initialLocation: '/accounts/acc-1/edit',
|
||||||
@@ -65,8 +66,9 @@ void main() {
|
|||||||
expect(find.text('No accounts yet.'), findsNothing);
|
expect(find.text('No accounts yet.'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('saving with new password runs connection test',
|
testWidgets('saving with new password runs connection test', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(neverRepo),
|
emailRepositoryProvider.overrideWithValue(neverRepo),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -42,10 +44,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(emailDetail: email, emailBody: body),
|
FakeEmailRepository(emailDetail: email, emailBody: body),
|
||||||
),
|
),
|
||||||
@@ -61,16 +65,21 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('shows from-address in header', (tester) async {
|
testWidgets('shows from-address in header', (tester) async {
|
||||||
final email = testEmail();
|
final email = testEmail();
|
||||||
const body =
|
const body = EmailBody(
|
||||||
EmailBody(emailId: 'acc-1:42', textBody: 'Hi', attachments: []);
|
emailId: 'acc-1:42',
|
||||||
|
textBody: 'Hi',
|
||||||
|
attachments: [],
|
||||||
|
);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(emailDetail: email, emailBody: body),
|
FakeEmailRepository(emailDetail: email, emailBody: body),
|
||||||
),
|
),
|
||||||
@@ -82,8 +91,9 @@ void main() {
|
|||||||
expect(find.textContaining('bob@example.com'), findsOneWidget);
|
expect(find.textContaining('bob@example.com'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows attachment section when email has attachments',
|
testWidgets('shows attachment section when email has attachments', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
final email = testEmail(hasAttachment: true);
|
final email = testEmail(hasAttachment: true);
|
||||||
const body = EmailBody(
|
const body = EmailBody(
|
||||||
emailId: 'acc-1:42',
|
emailId: 'acc-1:42',
|
||||||
@@ -100,10 +110,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(emailDetail: email, emailBody: body),
|
FakeEmailRepository(emailDetail: email, emailBody: body),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -34,12 +36,15 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
emailRepositoryProvider
|
FakeMailboxRepository(),
|
||||||
.overrideWithValue(FakeEmailRepository(emails: [email])),
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(emails: [email]),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -55,12 +60,15 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
emailRepositoryProvider
|
FakeMailboxRepository(),
|
||||||
.overrideWithValue(FakeEmailRepository(emails: [email])),
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(emails: [email]),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -74,10 +82,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -91,16 +101,19 @@ void main() {
|
|||||||
expect(find.text('Search…'), findsOneWidget);
|
expect(find.text('Search…'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('submitting a search query shows "No results" when empty',
|
testWidgets('submitting a search query shows "No results" when empty', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -117,17 +130,20 @@ void main() {
|
|||||||
expect(find.text('No results'), findsOneWidget);
|
expect(find.text('No results'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('submitting a search query shows matching emails',
|
testWidgets('submitting a search query shows matching emails', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
final email = testEmail(subject: 'Found it');
|
final email = testEmail(subject: 'Found it');
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(searchResults: [email]),
|
FakeEmailRepository(searchResults: [email]),
|
||||||
),
|
),
|
||||||
@@ -151,10 +167,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -167,16 +185,19 @@ void main() {
|
|||||||
// No assertion needed — we just verify the tap doesn't throw.
|
// No assertion needed — we just verify the tap doesn't throw.
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping edit button navigates to compose screen',
|
testWidgets('tapping edit button navigates to compose screen', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -194,10 +215,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -209,19 +232,23 @@ void main() {
|
|||||||
expect(find.text('INBOX'), findsOneWidget);
|
expect(find.text('INBOX'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('long-press enters selection mode with selection bar',
|
testWidgets('long-press enters selection mode with selection bar', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
final email = testEmail(subject: 'Select me');
|
final email = testEmail(subject: 'Select me');
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
emailRepositoryProvider
|
FakeMailboxRepository(),
|
||||||
.overrideWithValue(FakeEmailRepository(emails: [email])),
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(emails: [email]),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -235,19 +262,23 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.close), findsOneWidget);
|
expect(find.byIcon(Icons.close), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('selection bar close button exits selection mode',
|
testWidgets('selection bar close button exits selection mode', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
final email = testEmail(subject: 'Select me');
|
final email = testEmail(subject: 'Select me');
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
emailRepositoryProvider
|
FakeMailboxRepository(),
|
||||||
.overrideWithValue(FakeEmailRepository(emails: [email])),
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(emails: [email]),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -263,17 +294,20 @@ void main() {
|
|||||||
expect(find.byType(BottomAppBar), findsNothing);
|
expect(find.byType(BottomAppBar), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping clear icon in search bar clears results',
|
testWidgets('tapping clear icon in search bar clears results', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
final email = testEmail(subject: 'Found it');
|
final email = testEmail(subject: 'Found it');
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(emails: [], searchResults: [email]),
|
FakeEmailRepository(emails: [], searchResults: [email]),
|
||||||
),
|
),
|
||||||
@@ -303,12 +337,15 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
emailRepositoryProvider
|
FakeMailboxRepository(),
|
||||||
.overrideWithValue(FakeEmailRepository(emails: [email])),
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(emails: [email]),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -325,23 +362,28 @@ void main() {
|
|||||||
expect(find.text('INBOX'), findsOneWidget);
|
expect(find.text('INBOX'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping a search result navigates to email detail',
|
testWidgets('tapping a search result navigates to email detail', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
final email = testEmail(subject: 'Result email');
|
final email = testEmail(subject: 'Result email');
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(
|
FakeEmailRepository(
|
||||||
searchResults: [email],
|
searchResults: [email],
|
||||||
emailDetail: email,
|
emailDetail: email,
|
||||||
emailBody:
|
emailBody: const EmailBody(
|
||||||
const EmailBody(emailId: 'acc-1:42', attachments: []),
|
emailId: 'acc-1:42',
|
||||||
|
attachments: [],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -384,12 +426,15 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
emailRepositoryProvider
|
FakeMailboxRepository(),
|
||||||
.overrideWithValue(FakeEmailRepository(emails: [email])),
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(emails: [email]),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -306,7 +306,9 @@ class FakeConnectionTestService implements ConnectionTestService {
|
|||||||
|
|
||||||
class _NoOpManageSieveProbeService implements ManageSieveProbeService {
|
class _NoOpManageSieveProbeService implements ManageSieveProbeService {
|
||||||
@override
|
@override
|
||||||
Future<void> probe(Account account) async {/* no-op in tests */}
|
Future<void> probe(Account account) async {
|
||||||
|
/* no-op in tests */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -340,9 +342,8 @@ Widget buildApp({
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/search',
|
path: ':accountId/search',
|
||||||
builder: (ctx, state) => SearchScreen(
|
builder: (ctx, state) =>
|
||||||
accountId: state.pathParameters['accountId']!,
|
SearchScreen(accountId: state.pathParameters['accountId']!),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/emails/by-address/:address',
|
path: ':accountId/emails/by-address/:address',
|
||||||
@@ -399,8 +400,9 @@ Widget buildApp({
|
|||||||
// their own override before this default in [overrides].
|
// their own override before this default in [overrides].
|
||||||
overrides: [
|
overrides: [
|
||||||
...overrides,
|
...overrides,
|
||||||
manageSieveProbeServiceProvider
|
manageSieveProbeServiceProvider.overrideWith(
|
||||||
.overrideWith((ref) => _NoOpManageSieveProbeService()),
|
(ref) => _NoOpManageSieveProbeService(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
routerConfig: testRouter,
|
routerConfig: testRouter,
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes',
|
initialLocation: '/accounts/acc-1/mailboxes',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository([kTestMailbox])),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository([kTestMailbox]),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -31,10 +33,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes',
|
initialLocation: '/accounts/acc-1/mailboxes',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository([kTestMailbox])),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository([kTestMailbox]),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -45,16 +49,19 @@ void main() {
|
|||||||
expect(find.text('3'), findsOneWidget);
|
expect(find.text('3'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping a mailbox tile navigates to its email list',
|
testWidgets('tapping a mailbox tile navigates to its email list', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes',
|
initialLocation: '/accounts/acc-1/mailboxes',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository([kTestMailbox])),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository([kTestMailbox]),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -80,10 +87,12 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes',
|
initialLocation: '/accounts/acc-1/mailboxes',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider
|
accountRepositoryProvider.overrideWithValue(
|
||||||
.overrideWithValue(FakeAccountRepository([kTestAccount])),
|
FakeAccountRepository([kTestAccount]),
|
||||||
mailboxRepositoryProvider
|
),
|
||||||
.overrideWithValue(FakeMailboxRepository([emptyMailbox])),
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository([emptyMailbox]),
|
||||||
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,18 +15,14 @@ void main() {
|
|||||||
group('TryConnectionButton', () {
|
group('TryConnectionButton', () {
|
||||||
testWidgets('shows "Try connection" button when idle', (tester) async {
|
testWidgets('shows "Try connection" button when idle', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
_wrap(
|
_wrap(const TryConnectionButton(testing: false, onPressed: null)),
|
||||||
const TryConnectionButton(testing: false, onPressed: null),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(find.text('Try connection'), findsOneWidget);
|
expect(find.text('Try connection'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows spinner when testing', (tester) async {
|
testWidgets('shows spinner when testing', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
_wrap(
|
_wrap(const TryConnectionButton(testing: true, onPressed: null)),
|
||||||
const TryConnectionButton(testing: true, onPressed: null),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
expect(find.text('Try connection'), findsNothing);
|
expect(find.text('Try connection'), findsNothing);
|
||||||
|
|||||||
Reference in New Issue
Block a user