Files
sharedinbox/test/unit/email_repository_contract_test.dart

223 lines
6.9 KiB
Dart

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:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/data/db/database.dart' hide Account;
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart';
// ── Contract ──────────────────────────────────────────────────────────────────
/// Verifies the observable / local-state portion of the [EmailRepository]
/// interface contract.
///
/// Network-dependent methods (syncEmails, sendEmail, etc.) are intentionally
/// excluded — they are covered by the concrete impl tests.
abstract class EmailRepositoryContract {
static const _account = Account(
id: 'er-acc',
displayName: 'Contract',
email: 'er@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
/// Return a fresh [EmailRepository] with [_account] already persisted.
Future<EmailRepository> makeRepo();
/// Insert a raw email row so tests can assert on observable state without
/// triggering a network sync.
Future<void> insertEmail(
EmailRepository repo, {
required String id,
required String mailboxPath,
bool isSeen = true,
bool isFlagged = false,
DateTime? receivedAt,
});
void run() {
test('observeEmails starts empty', () async {
final repo = await makeRepo();
expect(
await repo.observeEmails(_account.id, 'INBOX').first,
isEmpty,
);
});
test('observeEmails emits inserted email', () async {
final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
final emails = await repo.observeEmails(_account.id, 'INBOX').first;
expect(emails, hasLength(1));
expect(emails.first.id, 'er-acc:1');
});
test('observeEmails only returns emails for the given mailbox', () async {
final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
expect(
await repo.observeEmails(_account.id, 'Sent').first,
isEmpty,
);
});
test('observeEmails orders by receivedAt descending', () async {
final repo = await makeRepo();
final older = DateTime(2024);
final newer = DateTime(2024, 6);
await insertEmail(
repo,
id: 'er-acc:1',
mailboxPath: 'INBOX',
receivedAt: older,
);
await insertEmail(
repo,
id: 'er-acc:2',
mailboxPath: 'INBOX',
receivedAt: newer,
);
final emails = await repo.observeEmails(_account.id, 'INBOX').first;
expect(emails.first.id, 'er-acc:2');
expect(emails.last.id, 'er-acc:1');
});
test('getEmail returns null for unknown id', () async {
final repo = await makeRepo();
expect(await repo.getEmail('no-such'), isNull);
});
test('getEmail returns inserted email', () async {
final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:7', mailboxPath: 'INBOX');
final email = await repo.getEmail('er-acc:7');
expect(email, isNotNull);
expect(email!.accountId, _account.id);
});
test('setFlag seen updates isSeen', () async {
final repo = await makeRepo();
await insertEmail(
repo,
id: 'er-acc:10',
mailboxPath: 'INBOX',
isSeen: false,
);
await repo.setFlag('er-acc:10', seen: true);
final email = await repo.getEmail('er-acc:10');
expect(email!.isSeen, isTrue);
});
test('setFlag flagged updates isFlagged', () async {
final repo = await makeRepo();
await insertEmail(
repo,
id: 'er-acc:11',
mailboxPath: 'INBOX',
);
await repo.setFlag('er-acc:11', flagged: true);
final email = await repo.getEmail('er-acc:11');
expect(email!.isFlagged, isTrue);
});
test('markAllAsRead marks every unread email in the mailbox', () async {
final repo = await makeRepo();
await insertEmail(
repo,
id: 'er-acc:20',
mailboxPath: 'INBOX',
isSeen: false,
);
await insertEmail(
repo,
id: 'er-acc:21',
mailboxPath: 'INBOX',
isSeen: false,
);
await insertEmail(
repo,
id: 'er-acc:22',
mailboxPath: 'Sent',
isSeen: false,
);
await repo.markAllAsRead(_account.id, 'INBOX');
expect((await repo.getEmail('er-acc:20'))!.isSeen, isTrue);
expect((await repo.getEmail('er-acc:21'))!.isSeen, isTrue);
// Email in a different mailbox should be untouched.
expect((await repo.getEmail('er-acc:22'))!.isSeen, isFalse);
});
test('observeThreads starts empty', () async {
final repo = await makeRepo();
expect(
await repo.observeThreads(_account.id, 'INBOX').first,
isEmpty,
);
});
}
}
// ── Impl under test ───────────────────────────────────────────────────────────
class _EmailRepositoryImplContract extends EmailRepositoryContract {
static const _account = EmailRepositoryContract._account;
late AppDatabase _db;
late AccountRepositoryImpl _accountRepo;
@override
Future<EmailRepository> makeRepo() async {
_db = openTestDatabase();
_accountRepo = AccountRepositoryImpl(_db, MapSecureStorage());
await _accountRepo.addAccount(_account, 'pw');
return EmailRepositoryImpl(
_db,
_accountRepo,
imapConnect: (_, __, ___) => Future<imap.ImapClient>.error(
UnsupportedError('no IMAP in unit tests'),
),
smtpConnect: (_, __, ___) => Future<imap.SmtpClient>.error(
UnsupportedError('no SMTP in unit tests'),
),
);
}
@override
Future<void> insertEmail(
EmailRepository repo, {
required String id,
required String mailboxPath,
bool isSeen = true,
bool isFlagged = false,
DateTime? receivedAt,
}) async {
await _db.into(_db.emails).insert(
EmailsCompanion.insert(
id: id,
accountId: _account.id,
mailboxPath: mailboxPath,
uid: int.parse(id.split(':').last),
receivedAt: receivedAt ?? DateTime.now(),
isSeen: Value(isSeen),
isFlagged: Value(isFlagged),
),
);
}
}
void main() {
setUpAll(configureSqliteForTests);
group('EmailRepositoryImpl satisfies EmailRepository contract', () {
_EmailRepositoryImplContract().run();
});
}