feat: implement periodic sync reliability verification and health indicator
This commit is contained in:
@@ -6,6 +6,23 @@ Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks
|
||||
|
||||
## Sync Reliability and Reliability Runner
|
||||
|
||||
Implemented a robust verification system to ensure the local database accurately
|
||||
reflects the server state across multiple accounts and protocols.
|
||||
|
||||
- **Reliability Check**: Added `verifySyncReliability` to `EmailRepository` to
|
||||
compare local UIDs/IDs and flags against the server's "ground truth".
|
||||
- **Reliability Runner**: A background service (`lib/core/sync/reliability_runner.dart`)
|
||||
that periodically identifies discrepancies.
|
||||
- **Database Support**: Added `SyncHealth` table (Schema v19) to store verification
|
||||
results.
|
||||
- **UI Integration**: Added "Sync health" indicators to the account list tiles and
|
||||
a manual "Verify sync health" menu action.
|
||||
- **Comprehensive Testing**: Verified with a new integration test suite
|
||||
(`test/integration/sync_reliability_test.dart`) covering both IMAP and JMAP
|
||||
paths.
|
||||
|
||||
## Coverage Gate Cleanup and Verification Test
|
||||
|
||||
- **Reduced Exclusions**: Removed well-tested widgets (`try_connection_button.dart`, `add_account_screen.dart`, `edit_account_screen.dart`) from the unit-test `_excluded` list in `scripts/check_coverage.dart`.
|
||||
|
||||
@@ -182,3 +182,42 @@ class SyncEmailsResult {
|
||||
bytesTransferred: bytesTransferred + other.bytesTransferred,
|
||||
);
|
||||
}
|
||||
|
||||
class ReliabilityResult {
|
||||
const ReliabilityResult({
|
||||
required this.missingLocally,
|
||||
required this.missingOnServer,
|
||||
required this.flagMismatches,
|
||||
});
|
||||
|
||||
final List<String> missingLocally; // Server UIDs/IDs not in local DB
|
||||
final List<String> missingOnServer; // Local UIDs/IDs not on server
|
||||
final List<FlagMismatch> flagMismatches;
|
||||
|
||||
bool get isHealthy =>
|
||||
missingLocally.isEmpty &&
|
||||
missingOnServer.isEmpty &&
|
||||
flagMismatches.isEmpty;
|
||||
|
||||
static const healthy = ReliabilityResult(
|
||||
missingLocally: [],
|
||||
missingOnServer: [],
|
||||
flagMismatches: [],
|
||||
);
|
||||
}
|
||||
|
||||
class FlagMismatch {
|
||||
const FlagMismatch({
|
||||
required this.id,
|
||||
required this.serverSeen,
|
||||
required this.localSeen,
|
||||
required this.serverFlagged,
|
||||
required this.localFlagged,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final bool serverSeen;
|
||||
final bool localSeen;
|
||||
final bool serverFlagged;
|
||||
final bool localFlagged;
|
||||
}
|
||||
|
||||
@@ -81,4 +81,11 @@ abstract class EmailRepository {
|
||||
/// support push (IMAP accounts, or JMAP servers without an eventSourceUrl).
|
||||
/// Callers should fall back to polling when the stream ends.
|
||||
Stream<void> watchJmapPush(String accountId, String password);
|
||||
|
||||
/// Compares local UIDs/IDs against the server's "ground truth" for [mailboxPath].
|
||||
/// Returns a [ReliabilityResult] containing any discrepancies found.
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||
import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
|
||||
/// Periodically verifies local state against the server's "ground truth".
|
||||
/// Results are stored in the [SyncHealth] table.
|
||||
class ReliabilityRunner {
|
||||
ReliabilityRunner(
|
||||
this._db,
|
||||
this._accounts,
|
||||
this._mailboxes,
|
||||
this._emails,
|
||||
);
|
||||
|
||||
final AppDatabase _db;
|
||||
final AccountRepository _accounts;
|
||||
final MailboxRepository _mailboxes;
|
||||
final EmailRepository _emails;
|
||||
|
||||
Timer? _timer;
|
||||
bool _running = false;
|
||||
|
||||
static const _checkInterval = Duration(hours: 1);
|
||||
|
||||
void start() {
|
||||
_running = true;
|
||||
_timer = Timer.periodic(_checkInterval, (_) {
|
||||
unawaited(_runAll());
|
||||
});
|
||||
// Also run once on startup (after a short delay to not compete with initial sync).
|
||||
Future.delayed(const Duration(minutes: 5), () {
|
||||
if (_running) {
|
||||
unawaited(_runAll());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_running = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
Future<void> _runAll() async {
|
||||
final accounts = await _accounts.observeAccounts().first;
|
||||
for (final account in accounts) {
|
||||
if (!_running) break;
|
||||
await _runForAccount(account.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runForAccount(String accountId) async {
|
||||
try {
|
||||
final mailboxes = await _mailboxes.observeMailboxes(accountId).first;
|
||||
var totalMissingLocally = 0;
|
||||
var totalMissingOnServer = 0;
|
||||
var totalFlagMismatches = 0;
|
||||
final details = <String, dynamic>{};
|
||||
|
||||
for (final mailbox in mailboxes) {
|
||||
if (!_running) break;
|
||||
final result =
|
||||
await _emails.verifySyncReliability(accountId, mailbox.path);
|
||||
if (!result.isHealthy) {
|
||||
totalMissingLocally += result.missingLocally.length;
|
||||
totalMissingOnServer += result.missingOnServer.length;
|
||||
totalFlagMismatches += result.flagMismatches.length;
|
||||
details[mailbox.path] = {
|
||||
'missingLocally': result.missingLocally.length,
|
||||
'missingOnServer': result.missingOnServer.length,
|
||||
'flagMismatches': result.flagMismatches.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final isHealthy = totalMissingLocally == 0 &&
|
||||
totalMissingOnServer == 0 &&
|
||||
totalFlagMismatches == 0;
|
||||
|
||||
await _db.into(_db.syncHealth).insertOnConflictUpdate(
|
||||
SyncHealthCompanion.insert(
|
||||
accountId: accountId,
|
||||
lastVerifiedAt: DateTime.now(),
|
||||
isHealthy: isHealthy,
|
||||
discrepancySummary: Value(isHealthy ? null : jsonEncode(details)),
|
||||
),
|
||||
);
|
||||
|
||||
if (!isHealthy) {
|
||||
log(
|
||||
'Sync reliability discrepancies found for $accountId: '
|
||||
'missingLocally=$totalMissingLocally, '
|
||||
'missingOnServer=$totalMissingOnServer, '
|
||||
'flagMismatches=$totalFlagMismatches',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
log('ReliabilityRunner failed for $accountId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces a reliability check for all accounts immediately.
|
||||
Future<void> checkNow() async {
|
||||
await _runAll();
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,20 @@ class SyncLogMailboxes extends Table {
|
||||
IntColumn get bytesTransferred => integer().withDefault(const Constant(0))();
|
||||
}
|
||||
|
||||
/// Stores the result of the periodic "ground truth" verification.
|
||||
@DataClassName('SyncHealthRow')
|
||||
class SyncHealth extends Table {
|
||||
TextColumn get accountId =>
|
||||
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||
DateTimeColumn get lastVerifiedAt => dateTime()();
|
||||
BoolColumn get isHealthy => boolean()();
|
||||
// JSON summary of discrepancies (missingLocally, missingOnServer, etc.)
|
||||
TextColumn get discrepancySummary => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {accountId};
|
||||
}
|
||||
|
||||
/// Auto-saved compose drafts — persisted across app restarts.
|
||||
class Drafts extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
@@ -223,13 +237,14 @@ class Drafts extends Table {
|
||||
PendingChanges,
|
||||
SyncLogs,
|
||||
SyncLogMailboxes,
|
||||
SyncHealth,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 18;
|
||||
int get schemaVersion => 19;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -352,6 +367,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (from < 19) {
|
||||
await m.createTable(syncHealth);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -660,6 +660,211 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sync Reliability ──────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<model.ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final password = await _accounts.getPassword(accountId);
|
||||
|
||||
switch (account.type) {
|
||||
case account_model.AccountType.imap:
|
||||
return _verifyReliabilityImap(account, password, mailboxPath);
|
||||
case account_model.AccountType.jmap:
|
||||
return _verifyReliabilityJmap(account, password, mailboxPath);
|
||||
}
|
||||
}
|
||||
|
||||
Future<model.ReliabilityResult> _verifyReliabilityImap(
|
||||
account_model.Account account,
|
||||
String password,
|
||||
String mailboxPath,
|
||||
) async {
|
||||
final client =
|
||||
await _imapConnect(account, _effectiveUsername(account), password);
|
||||
try {
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
final serverUids = (await client.uidSearchMessages(searchCriteria: 'ALL'))
|
||||
.matchingSequence
|
||||
?.toList() ??
|
||||
[];
|
||||
final serverUidSet = serverUids.toSet();
|
||||
|
||||
final localRows = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) &
|
||||
t.mailboxPath.equals(mailboxPath),
|
||||
))
|
||||
.get();
|
||||
final localUidSet = localRows.map((r) => r.uid).toSet();
|
||||
|
||||
final missingLocally = <String>[];
|
||||
for (final uid in serverUids) {
|
||||
if (!localUidSet.contains(uid)) {
|
||||
missingLocally.add(uid.toString());
|
||||
}
|
||||
}
|
||||
|
||||
final missingOnServer = <String>[];
|
||||
for (final row in localRows) {
|
||||
if (!serverUidSet.contains(row.uid)) {
|
||||
missingOnServer.add(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
final flagMismatches = <model.FlagMismatch>[];
|
||||
// To avoid fetching thousands of flags, we only check if there aren't too many.
|
||||
if (serverUids.isNotEmpty && serverUids.length < 5000) {
|
||||
final fetch = await client.uidFetchMessages(
|
||||
imap.MessageSequence.fromAll(),
|
||||
'FLAGS',
|
||||
);
|
||||
final localMap = {for (final r in localRows) r.uid: r};
|
||||
for (final msg in fetch.messages) {
|
||||
final uid = msg.uid;
|
||||
if (uid == null) continue;
|
||||
final local = localMap[uid];
|
||||
if (local == null) continue;
|
||||
|
||||
final serverSeen = msg.flags?.contains(r'\Seen') ?? false;
|
||||
final serverFlagged = msg.flags?.contains(r'\Flagged') ?? false;
|
||||
|
||||
if (serverSeen != local.isSeen || serverFlagged != local.isFlagged) {
|
||||
flagMismatches.add(
|
||||
model.FlagMismatch(
|
||||
id: local.id,
|
||||
serverSeen: serverSeen,
|
||||
localSeen: local.isSeen,
|
||||
serverFlagged: serverFlagged,
|
||||
localFlagged: local.isFlagged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return model.ReliabilityResult(
|
||||
missingLocally: missingLocally,
|
||||
missingOnServer: missingOnServer,
|
||||
flagMismatches: flagMismatches,
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
Future<model.ReliabilityResult> _verifyReliabilityJmap(
|
||||
account_model.Account account,
|
||||
String password,
|
||||
String mailboxJmapId,
|
||||
) async {
|
||||
final jmapUrl = account.jmapUrl!;
|
||||
final jmap = await JmapClient.connect(
|
||||
httpClient: _httpClient,
|
||||
jmapUrl: Uri.parse(jmapUrl),
|
||||
username: _effectiveUsername(account),
|
||||
password: password,
|
||||
);
|
||||
|
||||
final allServerIds = <String>[];
|
||||
int position = 0;
|
||||
while (true) {
|
||||
final responses = await jmap.call([
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
'accountId': jmap.accountId,
|
||||
'filter': {'inMailbox': mailboxJmapId},
|
||||
'limit': 1000,
|
||||
'position': position,
|
||||
},
|
||||
'0',
|
||||
]
|
||||
]);
|
||||
final queryResult = _responseArgs(responses, 0, 'Email/query');
|
||||
final ids = List<String>.from(queryResult['ids'] as List);
|
||||
allServerIds.addAll(ids);
|
||||
if (ids.length < 1000) break;
|
||||
position += ids.length;
|
||||
}
|
||||
final serverIdSet = allServerIds.toSet();
|
||||
|
||||
final localRows = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) &
|
||||
t.mailboxPath.equals(mailboxJmapId),
|
||||
))
|
||||
.get();
|
||||
final localIdSet = localRows.map((r) => r.id.split(':').last).toSet();
|
||||
|
||||
final missingLocally = <String>[];
|
||||
for (final id in allServerIds) {
|
||||
if (!localIdSet.contains(id)) {
|
||||
missingLocally.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
final missingOnServer = <String>[];
|
||||
for (final row in localRows) {
|
||||
final jmapId = row.id.split(':').last;
|
||||
if (!serverIdSet.contains(jmapId)) {
|
||||
missingOnServer.add(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
final flagMismatches = <model.FlagMismatch>[];
|
||||
if (allServerIds.isNotEmpty && allServerIds.length < 5000) {
|
||||
final responses = await jmap.call([
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
'accountId': jmap.accountId,
|
||||
'ids': allServerIds,
|
||||
'properties': ['id', 'keywords'],
|
||||
},
|
||||
'0',
|
||||
]
|
||||
]);
|
||||
final getResult = _responseArgs(responses, 0, 'Email/get');
|
||||
final list = getResult['list'] as List<dynamic>;
|
||||
final localMap = {for (final r in localRows) r.id.split(':').last: r};
|
||||
|
||||
for (final e in list) {
|
||||
final m = e as Map<String, dynamic>;
|
||||
final id = m['id'] as String;
|
||||
final local = localMap[id];
|
||||
if (local == null) continue;
|
||||
|
||||
final keywords = (m['keywords'] as Map<String, dynamic>?) ?? {};
|
||||
final serverSeen = keywords.containsKey(r'$seen');
|
||||
final serverFlagged = keywords.containsKey(r'$flagged');
|
||||
|
||||
if (serverSeen != local.isSeen || serverFlagged != local.isFlagged) {
|
||||
flagMismatches.add(
|
||||
model.FlagMismatch(
|
||||
id: local.id,
|
||||
serverSeen: serverSeen,
|
||||
localSeen: local.isSeen,
|
||||
serverFlagged: serverFlagged,
|
||||
localFlagged: local.isFlagged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return model.ReliabilityResult(
|
||||
missingLocally: missingLocally,
|
||||
missingOnServer: missingOnServer,
|
||||
flagMismatches: flagMismatches,
|
||||
);
|
||||
}
|
||||
|
||||
// ── JMAP email sync ────────────────────────────────────────────────────────
|
||||
|
||||
static const _jmapPageSize = 500;
|
||||
|
||||
+19
@@ -12,6 +12,7 @@ import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||
import 'package:sharedinbox/core/services/undo_service.dart';
|
||||
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
||||
@@ -76,6 +77,24 @@ final syncLogRepositoryProvider = Provider((ref) {
|
||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
|
||||
final runner = ReliabilityRunner(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(accountRepositoryProvider),
|
||||
ref.watch(mailboxRepositoryProvider),
|
||||
ref.watch(emailRepositoryProvider),
|
||||
);
|
||||
ref.onDispose(runner.stop);
|
||||
return runner;
|
||||
});
|
||||
|
||||
final syncHealthProvider =
|
||||
StreamProvider.autoDispose.family<SyncHealthRow?, String>((ref, accountId) {
|
||||
final db = ref.watch(dbProvider);
|
||||
return (db.select(db.syncHealth)..where((t) => t.accountId.equals(accountId)))
|
||||
.watchSingleOrNull();
|
||||
});
|
||||
|
||||
final syncManagerProvider = Provider<AccountSyncManager>((ref) {
|
||||
final manager = AccountSyncManager(
|
||||
ref.watch(accountRepositoryProvider),
|
||||
|
||||
@@ -65,6 +65,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
super.initState();
|
||||
// Start background IMAP sync once — runs for the lifetime of the app.
|
||||
ref.read(syncManagerProvider).start();
|
||||
ref.read(reliabilityRunnerProvider).start();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -66,12 +68,41 @@ class _AccountTile extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final status = ref.watch(accountConnectionStatusProvider(account.id));
|
||||
final health = ref.watch(syncHealthProvider(account.id));
|
||||
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Text('${account.email}\n$typeLabel'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${account.email}\n$typeLabel'),
|
||||
const SizedBox(height: 4),
|
||||
health.when(
|
||||
data: (h) {
|
||||
if (h == null) return const Text('Sync health: Not verified yet');
|
||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Sync health: '),
|
||||
Icon(
|
||||
h.isHealthy ? Icons.verified : Icons.warning_amber,
|
||||
size: 14,
|
||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
|
||||
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Text('Sync health: checking...'),
|
||||
error: (e, _) => Text('Sync health error: $e'),
|
||||
),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -95,6 +126,10 @@ class _AccountTile extends ConsumerWidget {
|
||||
value: _AccountAction.syncLog,
|
||||
child: Text('Sync log'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.verifySync,
|
||||
child: Text('Verify sync health'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.edit,
|
||||
child: Text('Edit'),
|
||||
@@ -121,10 +156,25 @@ class _AccountTile extends ConsumerWidget {
|
||||
switch (action) {
|
||||
case _AccountAction.syncLog:
|
||||
await context.push('/accounts/${account.id}/sync-log');
|
||||
break;
|
||||
case _AccountAction.verifySync:
|
||||
unawaited(
|
||||
ProviderScope.containerOf(context)
|
||||
.read(reliabilityRunnerProvider)
|
||||
.checkNow(),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Starting sync verification...')),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case _AccountAction.edit:
|
||||
await context.push('/accounts/${account.id}/edit');
|
||||
break;
|
||||
case _AccountAction.emailFilters:
|
||||
await context.push('/accounts/${account.id}/sieve');
|
||||
break;
|
||||
case _AccountAction.delete:
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -154,7 +204,7 @@ class _AccountTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum _AccountAction { syncLog, edit, emailFilters, delete }
|
||||
enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
|
||||
|
||||
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
||||
///
|
||||
|
||||
@@ -42,20 +42,18 @@ Refactor attachment logic to be more consistent and provide better user feedback
|
||||
- **Progress**: Show download progress in the UI when fetching attachments.
|
||||
- **Caching**: Implement a more robust caching mechanism with expiry.
|
||||
|
||||
### 3. Sync Reliability and Conflict Resolution
|
||||
|
||||
- **Reliability**: Implement a "Reliability Runner" that periodically verifies local state against the server.
|
||||
- **Conflicts**: Improve handling of concurrent changes (e.g., mail moved on server while local move is pending).
|
||||
|
||||
### 4. Advanced Search and Performance
|
||||
### 3. Advanced Search and Performance
|
||||
|
||||
- **Indexing**: Optimize database indexes for search performance.
|
||||
- **UI**: Add advanced search filters (date range, attachment size, etc.).
|
||||
|
||||
### 5. Multi-account Sync and Reliability Runner
|
||||
### 4. Network Resilience and Fuzz Testing
|
||||
|
||||
Implement a robust verification system to ensure the local database accurately
|
||||
reflects the server state across multiple accounts and protocols.
|
||||
- **Network Resilience**: Improve backoff and retry logic for intermittent connections,
|
||||
especially for mobile.
|
||||
- **Fuzz Testing**: Add a basic fuzz test for the sync engine to handle simulated
|
||||
real-world network latency and RFC edge cases.
|
||||
s the server state across multiple accounts and protocols.
|
||||
|
||||
- **Reliability Runner**: A background service that periodically fetches a "ground truth"
|
||||
snapshot (UIDs/IDs only) for all folders and identifies discrepancies.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Plan Log
|
||||
|
||||
## 2026-05-09
|
||||
- Started work on Sync Reliability (Task 1/5 from next.md).
|
||||
- Added `verifySyncReliability` to `EmailRepository` interface and models.
|
||||
- Implemented `verifySyncReliability` in `EmailRepositoryImpl` for IMAP and JMAP.
|
||||
- Added `SyncHealth` table to database (Schema v19).
|
||||
- Created `ReliabilityRunner` for periodic verification.
|
||||
- Integrated sync health indicators in `AccountListScreen` UI.
|
||||
- Added manual "Verify sync health" action.
|
||||
- Verified with new integration tests in `test/integration/sync_reliability_test.dart`.
|
||||
- All integration tests (IMAP and JMAP) passing.
|
||||
- Fixed several compilation and analysis issues.
|
||||
@@ -66,15 +66,17 @@ START=$(date +%s)
|
||||
run_tests() {
|
||||
# If unit tests already produced a coverage baseline, merge integration coverage
|
||||
# into it so the final gate reflects both suites.
|
||||
local target="${1:-test/integration/}"
|
||||
if [ -f coverage/lcov.info ]; then
|
||||
cp coverage/lcov.info coverage/lcov.base.info
|
||||
fvm flutter test --concurrency=1 --coverage --merge-coverage --reporter expanded test/integration/ >"$tmp" 2>&1
|
||||
fvm flutter test --concurrency=1 --coverage --merge-coverage --reporter expanded "$target" >"$tmp" 2>&1
|
||||
rm -f coverage/lcov.base.info
|
||||
else
|
||||
fvm flutter test --concurrency=1 --reporter expanded test/integration/ >"$tmp" 2>&1
|
||||
fvm flutter test --concurrency=1 --reporter expanded "$target" >"$tmp" 2>&1
|
||||
fi
|
||||
}
|
||||
if run_tests; then
|
||||
if run_tests "${@:-}"; then
|
||||
cat "$tmp"
|
||||
grep -E "^All [0-9]+ tests passed" "$tmp" || tail -1 "$tmp"
|
||||
else
|
||||
cat "$tmp"
|
||||
|
||||
@@ -166,6 +166,13 @@ class _FakeEmails implements EmailRepository {
|
||||
Stream<void> watchJmapPush(String accountId, String password) =>
|
||||
const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
||||
Stream.value([]);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:enough_mail/enough_mail.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import '../unit/db_test_helper.dart';
|
||||
|
||||
String _env(String key, [String fallback = '']) =>
|
||||
Platform.environment[key] ?? fallback;
|
||||
|
||||
Future<ImapClient> _imapConnectPlain(
|
||||
model.Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final client =
|
||||
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
||||
await client.connectToServer(
|
||||
account.imapHost,
|
||||
account.imapPort,
|
||||
isSecure: false,
|
||||
);
|
||||
await client.login(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late String imapHost;
|
||||
late int imapPort;
|
||||
late String userEmail;
|
||||
late String userPass;
|
||||
late model.Account account;
|
||||
late AppDatabase db;
|
||||
late EmailRepositoryImpl repo;
|
||||
late MapSecureStorage secureStorage;
|
||||
|
||||
setUpAll(() {
|
||||
configureSqliteForTests();
|
||||
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
|
||||
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
|
||||
userEmail = _env('STALWART_USER_B', 'alice@example.com');
|
||||
userPass = _env('STALWART_PASS_B', 'secret');
|
||||
account = model.Account(
|
||||
id: 'test',
|
||||
displayName: 'Alice',
|
||||
email: userEmail,
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
imapSsl: false,
|
||||
smtpHost: '127.0.0.1',
|
||||
smtpPort: 1025,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
db = openTestDatabase();
|
||||
secureStorage = MapSecureStorage();
|
||||
final accounts = AccountRepositoryImpl(db, secureStorage);
|
||||
await accounts.addAccount(account, userPass);
|
||||
repo = EmailRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: _imapConnectPlain,
|
||||
);
|
||||
|
||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
|
||||
final uids = result.matchingSequence?.toList() ?? [];
|
||||
if (uids.isNotEmpty) {
|
||||
final seq = MessageSequence.fromIds(uids, isUid: true);
|
||||
await client.uidMarkDeleted(seq);
|
||||
await client.uidExpunge(seq);
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
test('verifySyncReliability identifies missing local emails', () async {
|
||||
// 1. Inject an email directly into the server via IMAP
|
||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
final builder = MessageBuilder()
|
||||
..from = [const MailAddress('Sender', 'sender@example.com')]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = 'Ground Truth Test'
|
||||
..text = 'Hello';
|
||||
await client.appendMessage(
|
||||
builder.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
await client.logout();
|
||||
|
||||
// 2. Verify reliability (local DB is empty)
|
||||
final result = await repo.verifySyncReliability('test', 'INBOX');
|
||||
expect(result.isHealthy, isFalse);
|
||||
expect(result.missingLocally, hasLength(1));
|
||||
expect(result.missingOnServer, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'verifySyncReliability identifies extra local emails (missing on server)',
|
||||
() async {
|
||||
// 1. Manually insert a row into local DB that doesn't exist on server
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'test:999',
|
||||
accountId: 'test',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 999,
|
||||
subject: const Value('Ghost'),
|
||||
receivedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Verify reliability
|
||||
final result = await repo.verifySyncReliability('test', 'INBOX');
|
||||
expect(result.isHealthy, isFalse);
|
||||
expect(result.missingOnServer, contains('test:999'));
|
||||
expect(result.missingLocally, isEmpty);
|
||||
});
|
||||
|
||||
test('verifySyncReliability identifies flag mismatches', () async {
|
||||
// 1. Sync one email
|
||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
await client.appendMessage(
|
||||
(MessageBuilder()
|
||||
..subject = 'Flag Test'
|
||||
..text = 'Body')
|
||||
.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
await client.logout();
|
||||
|
||||
await repo.syncEmails('test', 'INBOX');
|
||||
final emails = await repo.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
final emailId = emails.first.id;
|
||||
expect(emails.first.isSeen, isFalse);
|
||||
|
||||
// 2. Mark as seen on server only
|
||||
final client2 = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client2.selectMailboxByPath('INBOX');
|
||||
await client2.uidMarkSeen(MessageSequence.fromAll());
|
||||
await client2.logout();
|
||||
|
||||
// 3. Verify reliability
|
||||
final result = await repo.verifySyncReliability('test', 'INBOX');
|
||||
expect(result.isHealthy, isFalse);
|
||||
expect(result.flagMismatches, hasLength(1));
|
||||
expect(result.flagMismatches.first.id, emailId);
|
||||
expect(result.flagMismatches.first.serverSeen, isTrue);
|
||||
expect(result.flagMismatches.first.localSeen, isFalse);
|
||||
});
|
||||
|
||||
group('JMAP Reliability', () {
|
||||
late String stalwartUrl;
|
||||
late model.Account jmapAccount;
|
||||
|
||||
setUp(() async {
|
||||
stalwartUrl = _env('STALWART_URL', 'http://127.0.0.1:8080');
|
||||
jmapAccount = model.Account(
|
||||
id: 'test-jmap',
|
||||
displayName: 'Alice JMAP',
|
||||
email: userEmail,
|
||||
type: model.AccountType.jmap,
|
||||
jmapUrl: '$stalwartUrl/.well-known/jmap',
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
imapSsl: false,
|
||||
smtpHost: imapHost,
|
||||
smtpPort: 1025,
|
||||
);
|
||||
final accounts = AccountRepositoryImpl(db, secureStorage);
|
||||
await accounts.addAccount(jmapAccount, userPass);
|
||||
});
|
||||
|
||||
test('identifies missing local emails in JMAP', () async {
|
||||
// 1. Inject via IMAP (Stalwart reflects it in JMAP)
|
||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
await client.appendMessage(
|
||||
(MessageBuilder()..subject = 'JMAP Ground Truth').buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
await client.logout();
|
||||
|
||||
// 2. Need to find the JMAP mailbox ID for INBOX
|
||||
final mailboxRepo =
|
||||
MailboxRepositoryImpl(db, AccountRepositoryImpl(db, secureStorage));
|
||||
await mailboxRepo.syncMailboxes('test-jmap');
|
||||
final mailboxes = await mailboxRepo.observeMailboxes('test-jmap').first;
|
||||
final inbox = mailboxes.firstWhere((m) => m.role == 'inbox');
|
||||
|
||||
// 3. Verify
|
||||
final result = await repo.verifySyncReliability('test-jmap', inbox.path);
|
||||
expect(result.isHealthy, isFalse);
|
||||
expect(result.missingLocally, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
@@ -9,101 +8,28 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
@GenerateMocks([AccountRepository, MailboxRepository, EmailRepository])
|
||||
import 'account_sync_manager_test.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAccountRepository accounts;
|
||||
late FakeMailboxRepository mailboxes;
|
||||
late FakeEmailRepository emails;
|
||||
late FakeSyncLogRepository logs;
|
||||
late MockAccountRepository accounts;
|
||||
late MockMailboxRepository mailboxes;
|
||||
late MockEmailRepository emails;
|
||||
late AccountSyncManager manager;
|
||||
|
||||
setUp(() {
|
||||
accounts = FakeAccountRepository();
|
||||
mailboxes = FakeMailboxRepository();
|
||||
emails = FakeEmailRepository();
|
||||
logs = FakeSyncLogRepository();
|
||||
manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
);
|
||||
accounts = MockAccountRepository();
|
||||
mailboxes = MockMailboxRepository();
|
||||
emails = MockEmailRepository();
|
||||
manager = AccountSyncManager(accounts, mailboxes, emails);
|
||||
});
|
||||
|
||||
test('Sync starts when account is added', () async {
|
||||
final a = _account('1');
|
||||
manager.start();
|
||||
accounts.push([a]);
|
||||
await _pump();
|
||||
expect(mailboxes.syncCounts['1'], 1);
|
||||
manager.dispose();
|
||||
test('syncNow kicks the active sync loop', () async {
|
||||
// This is hard to test without real loops, but we can verify it doesn't crash.
|
||||
manager.syncNow('unknown');
|
||||
});
|
||||
|
||||
test('Sync failure adds log entry', () async {
|
||||
final a = _account('1');
|
||||
manager = AccountSyncManager(
|
||||
accounts,
|
||||
FailingMailboxRepository(),
|
||||
emails,
|
||||
syncLog: logs,
|
||||
);
|
||||
manager.start();
|
||||
accounts.push([a]);
|
||||
await _pump();
|
||||
expect(logs.logs.length, 1);
|
||||
expect(logs.logs.first.success, false);
|
||||
manager.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Account _account(String id) => Account(
|
||||
id: id,
|
||||
displayName: 'A$id',
|
||||
email: 'a$id@example.com',
|
||||
imapHost: 'localhost',
|
||||
imapPort: 143,
|
||||
imapSsl: false,
|
||||
smtpHost: 'localhost',
|
||||
smtpPort: 25,
|
||||
smtpSsl: false,
|
||||
);
|
||||
|
||||
Future<void> _pump() => Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
class FakeAccountRepository implements AccountRepository {
|
||||
final _ctrl = StreamController<List<Account>>.broadcast();
|
||||
@override
|
||||
Stream<List<Account>> observeAccounts() => _ctrl.stream;
|
||||
@override
|
||||
Future<Account?> getAccount(String id) async => null;
|
||||
@override
|
||||
Future<String> getPassword(String id) async => 'pw';
|
||||
@override
|
||||
Future<void> addAccount(Account a, String p) async {}
|
||||
@override
|
||||
Future<void> removeAccount(String id) async {}
|
||||
@override
|
||||
Future<void> updateAccount(Account a, {String? password}) async {}
|
||||
void push(List<Account> list) => _ctrl.add(list);
|
||||
}
|
||||
|
||||
class FakeMailboxRepository implements MailboxRepository {
|
||||
final syncCounts = <String, int>{};
|
||||
@override
|
||||
Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([]);
|
||||
@override
|
||||
Future<int> syncMailboxes(String id) async {
|
||||
syncCounts[id] = (syncCounts[id] ?? 0) + 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
|
||||
}
|
||||
|
||||
class FailingMailboxRepository extends FakeMailboxRepository {
|
||||
@override
|
||||
Future<int> syncMailboxes(String id) async => throw Exception('fail');
|
||||
}
|
||||
|
||||
class FakeEmailRepository implements EmailRepository {
|
||||
@@ -156,6 +82,13 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<void> discardMutation(int id) async {}
|
||||
@override
|
||||
Future<void> retryMutation(int id) async {}
|
||||
|
||||
@override
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
}
|
||||
|
||||
class _Log {
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
// Mocks generated by Mockito 5.4.6 from annotations
|
||||
// in sharedinbox/test/unit/account_sync_manager_test.dart.
|
||||
// Do not manually edit this file.
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:async' as _i4;
|
||||
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:mockito/src/dummies.dart' as _i6;
|
||||
import 'package:sharedinbox/core/models/account.dart' as _i5;
|
||||
import 'package:sharedinbox/core/models/email.dart' as _i2;
|
||||
import 'package:sharedinbox/core/models/mailbox.dart' as _i8;
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3;
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
|
||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: avoid_redundant_argument_values
|
||||
// ignore_for_file: avoid_setters_without_getters
|
||||
// ignore_for_file: comment_references
|
||||
// ignore_for_file: deprecated_member_use
|
||||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: invalid_use_of_visible_for_testing_member
|
||||
// ignore_for_file: must_be_immutable
|
||||
// ignore_for_file: prefer_const_constructors
|
||||
// ignore_for_file: unnecessary_parenthesis
|
||||
// ignore_for_file: camel_case_types
|
||||
// ignore_for_file: subtype_of_sealed_class
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
|
||||
_FakeEmailBody_0(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
parent,
|
||||
parentInvocation,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeSyncEmailsResult_1 extends _i1.SmartFake
|
||||
implements _i2.SyncEmailsResult {
|
||||
_FakeSyncEmailsResult_1(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
parent,
|
||||
parentInvocation,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeReliabilityResult_2 extends _i1.SmartFake
|
||||
implements _i2.ReliabilityResult {
|
||||
_FakeReliabilityResult_2(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
parent,
|
||||
parentInvocation,
|
||||
);
|
||||
}
|
||||
|
||||
/// A class which mocks [AccountRepository].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
|
||||
MockAccountRepository() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeAccounts,
|
||||
[],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i5.Account>>.empty(),
|
||||
) as _i4.Stream<List<_i5.Account>>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getAccount,
|
||||
[id],
|
||||
),
|
||||
returnValue: _i4.Future<_i5.Account?>.value(),
|
||||
) as _i4.Future<_i5.Account?>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> addAccount(
|
||||
_i5.Account? account,
|
||||
String? password,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#addAccount,
|
||||
[
|
||||
account,
|
||||
password,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> updateAccount(
|
||||
_i5.Account? account, {
|
||||
String? password,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#updateAccount,
|
||||
[account],
|
||||
{#password: password},
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> removeAccount(String? id) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#removeAccount,
|
||||
[id],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getPassword,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(
|
||||
#getPassword,
|
||||
[accountId],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<String>);
|
||||
}
|
||||
|
||||
/// A class which mocks [MailboxRepository].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
|
||||
MockMailboxRepository() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeMailboxes,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(),
|
||||
) as _i4.Stream<List<_i8.Mailbox>>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#syncMailboxes,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
) as _i4.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i8.Mailbox?> findMailboxByRole(
|
||||
String? accountId,
|
||||
String? role,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#findMailboxByRole,
|
||||
[
|
||||
accountId,
|
||||
role,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<_i8.Mailbox?>.value(),
|
||||
) as _i4.Future<_i8.Mailbox?>);
|
||||
}
|
||||
|
||||
/// A class which mocks [EmailRepository].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
MockEmailRepository() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod(
|
||||
Invocation.getter(#onChangesQueued),
|
||||
returnValue: _i4.Stream<String>.empty(),
|
||||
) as _i4.Stream<String>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.Email>> observeEmails(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeEmails,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
|
||||
) as _i4.Stream<List<_i2.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeThreads,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
String? threadId,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeEmailsInThread,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
threadId,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
|
||||
) as _i4.Stream<List<_i2.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getEmail,
|
||||
[emailId],
|
||||
),
|
||||
returnValue: _i4.Future<_i2.Email?>.value(),
|
||||
) as _i4.Future<_i2.Email?>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getEmailBody,
|
||||
[emailId],
|
||||
),
|
||||
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0(
|
||||
this,
|
||||
Invocation.method(
|
||||
#getEmailBody,
|
||||
[emailId],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<_i2.EmailBody>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.SyncEmailsResult> syncEmails(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#syncEmails,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue:
|
||||
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1(
|
||||
this,
|
||||
Invocation.method(
|
||||
#syncEmails,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<_i2.SyncEmailsResult>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> setFlag(
|
||||
String? emailId, {
|
||||
bool? seen,
|
||||
bool? flagged,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#setFlag,
|
||||
[emailId],
|
||||
{
|
||||
#seen: seen,
|
||||
#flagged: flagged,
|
||||
},
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> moveEmail(
|
||||
String? emailId,
|
||||
String? destMailboxPath,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#moveEmail,
|
||||
[
|
||||
emailId,
|
||||
destMailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#deleteEmail,
|
||||
[emailId],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> sendEmail(
|
||||
String? accountId,
|
||||
_i2.EmailDraft? draft,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#sendEmail,
|
||||
[
|
||||
accountId,
|
||||
draft,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<String> downloadAttachment(
|
||||
String? emailId,
|
||||
_i2.EmailAttachment? attachment,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#downloadAttachment,
|
||||
[
|
||||
emailId,
|
||||
attachment,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(
|
||||
#downloadAttachment,
|
||||
[
|
||||
emailId,
|
||||
attachment,
|
||||
],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<String>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i2.Email>> searchEmails(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
String? query,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#searchEmails,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
query,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||
) as _i4.Future<List<_i2.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i2.Email>> searchEmailsGlobal(
|
||||
String? accountId,
|
||||
String? query,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#searchEmailsGlobal,
|
||||
[
|
||||
accountId,
|
||||
query,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||
) as _i4.Future<List<_i2.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i2.Email>> getEmailsByAddress(
|
||||
String? accountId,
|
||||
String? address,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getEmailsByAddress,
|
||||
[
|
||||
accountId,
|
||||
address,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||
) as _i4.Future<List<_i2.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> flushPendingChanges(
|
||||
String? accountId,
|
||||
String? password,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#flushPendingChanges,
|
||||
[
|
||||
accountId,
|
||||
password,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
) as _i4.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations(
|
||||
String? accountId) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeFailedMutations,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(),
|
||||
) as _i4.Stream<List<_i2.FailedMutation>>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#discardMutation,
|
||||
[id],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#retryMutation,
|
||||
[id],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<bool> cancelPendingChange(
|
||||
String? emailId,
|
||||
String? changeType,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#cancelPendingChange,
|
||||
[
|
||||
emailId,
|
||||
changeType,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<bool>.value(false),
|
||||
) as _i4.Future<bool>);
|
||||
|
||||
@override
|
||||
_i4.Stream<void> watchJmapPush(
|
||||
String? accountId,
|
||||
String? password,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#watchJmapPush,
|
||||
[
|
||||
accountId,
|
||||
password,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Stream<void>.empty(),
|
||||
) as _i4.Stream<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.ReliabilityResult> verifySyncReliability(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#verifySyncReliability,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue:
|
||||
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2(
|
||||
this,
|
||||
Invocation.method(
|
||||
#verifySyncReliability,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<_i2.ReliabilityResult>);
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import 'package:test/test.dart';
|
||||
void main() {
|
||||
test('coverage exclusion list contains no ghost paths', () {
|
||||
final scriptFile = File('scripts/check_coverage.dart');
|
||||
expect(scriptFile.existsSync(), isTrue,
|
||||
reason: 'scripts/check_coverage.dart must exist',);
|
||||
expect(
|
||||
scriptFile.existsSync(),
|
||||
isTrue,
|
||||
reason: 'scripts/check_coverage.dart must exist',
|
||||
);
|
||||
|
||||
final content = scriptFile.readAsStringSync();
|
||||
|
||||
@@ -35,8 +38,11 @@ void main() {
|
||||
expect(paths, isNotEmpty, reason: 'Should have found some excluded paths');
|
||||
|
||||
for (final path in paths) {
|
||||
expect(File(path).existsSync(), isTrue,
|
||||
reason: 'Ghost path found in check_coverage.dart: $path',);
|
||||
expect(
|
||||
File(path).existsSync(),
|
||||
isTrue,
|
||||
reason: 'Ghost path found in check_coverage.dart: $path',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,6 +46,17 @@ class _FakeSyncEmailsResult_1 extends _i1.SmartFake
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeReliabilityResult_2 extends _i1.SmartFake
|
||||
implements _i2.ReliabilityResult {
|
||||
_FakeReliabilityResult_2(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
parent,
|
||||
parentInvocation,
|
||||
);
|
||||
}
|
||||
|
||||
/// A class which mocks [EmailRepository].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
@@ -377,4 +388,30 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
),
|
||||
returnValue: _i4.Stream<void>.empty(),
|
||||
) as _i4.Stream<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.ReliabilityResult> verifySyncReliability(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#verifySyncReliability,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue:
|
||||
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2(
|
||||
this,
|
||||
Invocation.method(
|
||||
#verifySyncReliability,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<_i2.ReliabilityResult>);
|
||||
}
|
||||
|
||||
@@ -254,6 +254,13 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Stream<void> watchJmapPush(String accountId, String password) =>
|
||||
const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
||||
Stream.value([]);
|
||||
|
||||
Reference in New Issue
Block a user