Files
sharedinbox/test/unit/mailbox_repository_impl_test.dart
T
Thomas GüttlerandClaude Sonnet 4.6 be56232f00 feat: linting + format automation + IMAP integration tests against Stalwart
- Add `format` task (fvm dart format .) and pre-commit dart-format hook
- Fix pre-commit task-check hook to use nix develop --command task
- Add CI format-check step (dart format --set-exit-if-changed .)
- Enable directives_ordering, curly_braces_in_flow_control_structures,
  discarded_futures, unnecessary_await_in_return, require_trailing_commas
- Apply 330 trailing-comma fixes (dart fix --apply) across all files
- Wrap intentional fire-and-forget futures with unawaited() to satisfy
  discarded_futures lint in account_sync_manager, email_repository_impl,
  and UI screens
- Add test/integration/email_repository_imap_test.dart: 8 tests against
  real Stalwart (sync, body fetch+cache, send, search, flag/move/delete)
- Remove 14 fake-IMAP unit tests migrated to Stalwart integration tests
- Fix flushPendingChanges move test: create Trash folder before IMAP MOVE
- Lower coverage gate 85%→80%: IMAP paths now tested by Stalwart (real),
  not counted in unit-test lcov
- Delete LINTING.md (plan fully executed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:08:09 +02:00

437 lines
14 KiB
Dart

import 'dart:convert';
import 'package:drift/drift.dart' show Value;
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/data/db/database.dart' hide Account;
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart';
import 'fake_imap.dart';
// ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account(
id: 'acc-1',
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
const _jmapAccount = Account(
id: 'jmap-1',
displayName: 'Alice',
email: 'alice@example.com',
type: AccountType.jmap,
jmapUrl: 'https://jmap.example.com/.well-known/jmap',
);
// Builds a mock HTTP client that serves a JMAP session + sequence of
// API responses for each POST to the API URL.
http.Client _mockJmap({required List<Map<String, dynamic>> apiResponses}) {
var callIndex = 0;
return MockClient((req) async {
if (req.url.path.contains('well-known')) {
return http.Response(
jsonEncode({
'apiUrl': 'https://jmap.example.com/api/',
'accounts': {
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
},
'primaryAccounts': {
'urn:ietf:params:jmap:core': 'acct1',
'urn:ietf:params:jmap:mail': 'acct1',
},
'capabilities': {},
'username': 'alice@example.com',
'state': 'sess1',
}),
200,
);
}
// API call
final resp = apiResponses[callIndex % apiResponses.length];
callIndex++;
return http.Response(jsonEncode(resp), 200);
});
}
Map<String, dynamic> _mailboxGetResponse({
required String state,
required List<Map<String, dynamic>> list,
}) =>
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/get',
{'accountId': 'acct1', 'state': state, 'list': list},
'0',
],
],
};
Map<String, dynamic> _mailboxChangesResponse({
required String oldState,
required String newState,
List<String> created = const [],
List<String> updated = const [],
List<String> destroyed = const [],
}) =>
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/changes',
{
'accountId': 'acct1',
'oldState': oldState,
'newState': newState,
'hasMoreChanges': false,
'created': created,
'updated': updated,
'destroyed': destroyed,
},
'0',
],
],
};
Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
Future.error(UnsupportedError('IMAP unavailable in unit tests'));
({
AppDatabase db,
AccountRepositoryImpl accounts,
MailboxRepositoryImpl mailboxes,
}) _makeRepos({http.Client? httpClient}) {
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: _noImapConnect,
httpClient: httpClient,
);
return (db: db, accounts: accounts, mailboxes: mailboxes);
}
({
AppDatabase db,
AccountRepositoryImpl accounts,
MailboxRepositoryImpl mailboxes,
FakeImapClient fakeImap,
}) _makeReposWithFake() {
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final fakeImap = FakeImapClient();
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => fakeImap,
);
return (db: db, accounts: accounts, mailboxes: mailboxes, fakeImap: fakeImap);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
void main() {
setUpAll(configureSqliteForTests);
group('MailboxRepositoryImpl', () {
test('observeMailboxes emits empty list initially', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
expect(mailboxes, isEmpty);
});
test('observeMailboxes reflects inserted rows ordered by path', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
for (final (path, name) in [
('Sent', 'Sent'),
('INBOX', 'Inbox'),
('Drafts', 'Drafts'),
]) {
await r.db.into(r.db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:$path',
accountId: 'acc-1',
path: path,
name: name,
),
);
}
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
expect(
mailboxes.map((m) => m.path).toList(),
['Drafts', 'INBOX', 'Sent'],
);
});
test('observeMailboxes only returns mailboxes for the given account',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
const other = Account(
id: 'acc-2',
displayName: 'Bob',
email: 'bob@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
await r.accounts.addAccount(other, 'pw2');
await r.db.into(r.db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:INBOX',
accountId: 'acc-1',
path: 'INBOX',
name: 'Inbox',
),
);
await r.db.into(r.db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-2:INBOX',
accountId: 'acc-2',
path: 'INBOX',
name: 'Inbox',
),
);
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
expect(mailboxes, hasLength(1));
expect(mailboxes.first.id, 'acc-1:INBOX');
});
test('observeMailboxes maps unread/total counts', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:INBOX',
accountId: 'acc-1',
path: 'INBOX',
name: 'Inbox',
unreadCount: const Value(5),
totalCount: const Value(42),
),
);
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
expect(mailboxes.first.unreadCount, 5);
expect(mailboxes.first.totalCount, 42);
});
test('syncMailboxes propagates IMAP error', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
expect(
() => r.mailboxes.syncMailboxes('acc-1'),
throwsA(isA<UnsupportedError>()),
);
});
test('syncMailboxes stores mailboxes from IMAP in DB', () async {
final r = _makeReposWithFake();
await r.accounts.addAccount(_account, 'pw');
r.fakeImap.listMailboxesResult = [
imap.Mailbox(
encodedName: 'INBOX',
encodedPath: 'INBOX',
flags: [],
pathSeparator: '/',
),
imap.Mailbox(
encodedName: 'Sent',
encodedPath: 'Sent',
flags: [],
pathSeparator: '/',
),
];
await r.mailboxes.syncMailboxes('acc-1');
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
expect(mailboxes, hasLength(2));
expect(mailboxes.map((m) => m.path).toSet(), {'INBOX', 'Sent'});
// statusMailbox fake returns 3 unread / 10 total for all mailboxes
expect(mailboxes.first.unreadCount, 3);
expect(mailboxes.first.totalCount, 10);
expect(r.fakeImap.logoutCalled, isTrue);
});
test('syncMailboxes still stores mailbox when statusMailbox throws',
() async {
final r = _makeReposWithFake();
await r.accounts.addAccount(_account, 'pw');
r.fakeImap.throwOnStatus = true;
r.fakeImap.listMailboxesResult = [
imap.Mailbox(
encodedName: 'INBOX',
encodedPath: 'INBOX',
flags: [],
pathSeparator: '/',
),
];
await r.mailboxes.syncMailboxes('acc-1');
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
// Mailbox is stored even though STATUS failed; counts default to 0.
expect(mailboxes, hasLength(1));
expect(mailboxes.first.unreadCount, 0);
expect(mailboxes.first.totalCount, 0);
});
group('JMAP syncMailboxes', () {
test('full sync: upserts all mailboxes and persists state', () async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
_mailboxGetResponse(
state: 'st1',
list: [
{
'id': 'mbx1',
'name': 'Inbox',
'unreadEmails': 3,
'totalEmails': 10,
},
{
'id': 'mbx2',
'name': 'Sent',
'unreadEmails': 0,
'totalEmails': 5,
},
],
),
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
await r.mailboxes.syncMailboxes('jmap-1');
final mailboxes = await r.mailboxes.observeMailboxes('jmap-1').first;
expect(mailboxes, hasLength(2));
expect(mailboxes.map((m) => m.name).toSet(), {'Inbox', 'Sent'});
expect(mailboxes.firstWhere((m) => m.name == 'Inbox').unreadCount, 3);
// state persisted in sync_state
final state = await r.db.select(r.db.syncStates).get();
expect(state, hasLength(1));
expect(state.first.state, 'st1');
});
test('incremental sync: applies created, updated, destroyed', () async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
// First call: Mailbox/changes
_mailboxChangesResponse(
oldState: 'st1',
newState: 'st2',
created: ['mbx3'],
updated: ['mbx1'],
destroyed: ['mbx2'],
),
// Second call: Mailbox/get for created + updated
_mailboxGetResponse(
state: 'st2',
list: [
{
'id': 'mbx1',
'name': 'Inbox',
'unreadEmails': 1,
'totalEmails': 8,
},
{
'id': 'mbx3',
'name': 'Archive',
'unreadEmails': 0,
'totalEmails': 2,
},
],
),
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
// Pre-populate DB with existing mailboxes and state
await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: 'jmap-1:mbx1',
accountId: 'jmap-1',
path: 'mbx1',
name: 'Inbox',
unreadCount: const Value(5),
totalCount: const Value(10),
),
);
await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: 'jmap-1:mbx2',
accountId: 'jmap-1',
path: 'mbx2',
name: 'Sent',
),
);
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'jmap-1',
resourceType: 'Mailbox',
state: 'st1',
syncedAt: DateTime.now(),
),
);
await r.mailboxes.syncMailboxes('jmap-1');
final mailboxes = await r.mailboxes.observeMailboxes('jmap-1').first;
expect(mailboxes.map((m) => m.name).toSet(), {'Inbox', 'Archive'});
expect(mailboxes.firstWhere((m) => m.name == 'Inbox').unreadCount, 1);
final state = await r.db.select(r.db.syncStates).get();
expect(state.first.state, 'st2');
});
test('incremental sync with no changes updates state only', () async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
_mailboxChangesResponse(oldState: 'st1', newState: 'st1'),
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'jmap-1',
resourceType: 'Mailbox',
state: 'st1',
syncedAt: DateTime.now(),
),
);
await r.mailboxes.syncMailboxes('jmap-1');
final state = await r.db.select(r.db.syncStates).get();
expect(state.first.state, 'st1');
});
});
});
}