Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8a97defdf |
+1
-3
@@ -86,9 +86,7 @@ Files: `lib/ui/screens/search_screen.dart`, `lib/data/db/database.dart`.
|
|||||||
|
|
||||||
### U4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/28
|
### U4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/28
|
||||||
|
|
||||||
### U5 🟡 Accessible swipe actions on email list items
|
### U5 — Already implemented (Dismissible archive/delete swipes with undo, found in email_list_screen.dart)
|
||||||
Delete and Move are hidden behind long-press or detail-screen menus. Add leading/trailing swipe actions on the `EmailListScreen` tile (archive / delete) matching Material 3 patterns.
|
|
||||||
Files: `lib/ui/screens/email_list_screen.dart`.
|
|
||||||
|
|
||||||
### U6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/29
|
### U6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/29
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||||
|
|
||||||
|
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||||
|
import 'db_test_helper.dart';
|
||||||
|
|
||||||
|
// ── Contract ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Verifies the [AccountRepository] interface contract.
|
||||||
|
///
|
||||||
|
/// Subclass this and override [makeRepo] to run the same suite against any
|
||||||
|
/// concrete implementation.
|
||||||
|
abstract class AccountRepositoryContract {
|
||||||
|
AccountRepository makeRepo();
|
||||||
|
|
||||||
|
static const _a = Account(
|
||||||
|
id: 'c-1',
|
||||||
|
displayName: 'Contract',
|
||||||
|
email: 'c@example.com',
|
||||||
|
imapHost: 'imap.example.com',
|
||||||
|
smtpHost: 'smtp.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
void run() {
|
||||||
|
test('observeAccounts starts empty', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
expect(await repo.observeAccounts().first, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addAccount makes account visible via observeAccounts', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
final list = await repo.observeAccounts().first;
|
||||||
|
expect(list, hasLength(1));
|
||||||
|
expect(list.first.id, _a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAccount returns null for unknown id', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
expect(await repo.getAccount('no-such'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAccount returns added account', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
final a = await repo.getAccount(_a.id);
|
||||||
|
expect(a, isNotNull);
|
||||||
|
expect(a!.email, _a.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPassword returns stored password', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'secret123');
|
||||||
|
expect(await repo.getPassword(_a.id), 'secret123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAccount reflects changes in observeAccounts', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
final updated = _a.copyWith(displayName: 'Updated');
|
||||||
|
await repo.updateAccount(updated);
|
||||||
|
final list = await repo.observeAccounts().first;
|
||||||
|
expect(list.first.displayName, 'Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAccount with password updates stored password', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'old');
|
||||||
|
await repo.updateAccount(_a, password: 'new');
|
||||||
|
expect(await repo.getPassword(_a.id), 'new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeAccount makes account disappear from observeAccounts',
|
||||||
|
() async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
await repo.removeAccount(_a.id);
|
||||||
|
expect(await repo.observeAccounts().first, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAccount returns null after removeAccount', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
await repo.removeAccount(_a.id);
|
||||||
|
expect(await repo.getAccount(_a.id), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Impl under test ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _AccountRepositoryImplContract extends AccountRepositoryContract {
|
||||||
|
@override
|
||||||
|
AccountRepository makeRepo() =>
|
||||||
|
AccountRepositoryImpl(openTestDatabase(), MapSecureStorage());
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(configureSqliteForTests);
|
||||||
|
|
||||||
|
group('AccountRepositoryImpl satisfies AccountRepository contract', () {
|
||||||
|
_AccountRepositoryImplContract().run();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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('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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/mailbox_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/mailbox_repository_impl.dart';
|
||||||
|
|
||||||
|
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||||
|
import 'db_test_helper.dart';
|
||||||
|
|
||||||
|
// ── Contract ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Verifies the [MailboxRepository] interface contract.
|
||||||
|
///
|
||||||
|
/// Tests cover only the locally-observable part of the interface
|
||||||
|
/// (observe / find) since sync methods require live IMAP/JMAP servers.
|
||||||
|
abstract class MailboxRepositoryContract {
|
||||||
|
static const _account = Account(
|
||||||
|
id: 'm-acc',
|
||||||
|
displayName: 'Contract',
|
||||||
|
email: 'm@example.com',
|
||||||
|
imapHost: 'imap.example.com',
|
||||||
|
smtpHost: 'smtp.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Return a fresh [MailboxRepository] with [_account] already persisted.
|
||||||
|
Future<MailboxRepository> makeRepo();
|
||||||
|
|
||||||
|
/// Insert a mailbox row into the backing store so tests can verify
|
||||||
|
/// observeMailboxes without triggering a network sync.
|
||||||
|
Future<void> insertMailbox(
|
||||||
|
MailboxRepository repo, {
|
||||||
|
required String id,
|
||||||
|
required String path,
|
||||||
|
String? role,
|
||||||
|
int unread = 0,
|
||||||
|
int total = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
void run() {
|
||||||
|
test('observeMailboxes starts empty', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
expect(await repo.observeMailboxes(_account.id).first, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observeMailboxes emits inserted rows ordered by path', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertMailbox(repo, id: 'z', path: 'Z');
|
||||||
|
await insertMailbox(repo, id: 'a', path: 'A');
|
||||||
|
final boxes = await repo.observeMailboxes(_account.id).first;
|
||||||
|
expect(boxes.map((b) => b.path), ['A', 'Z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observeMailboxes only returns rows for the given account', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertMailbox(repo, id: 'mb1', path: 'INBOX');
|
||||||
|
expect(await repo.observeMailboxes('other-acc').first, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findMailboxByRole returns null when no match', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
expect(
|
||||||
|
await repo.findMailboxByRole(_account.id, 'archive'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findMailboxByRole returns the matching mailbox', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertMailbox(repo, id: 'arch', path: 'Archive', role: 'archive');
|
||||||
|
final box = await repo.findMailboxByRole(_account.id, 'archive');
|
||||||
|
expect(box, isNotNull);
|
||||||
|
expect(box!.role, 'archive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearForResync removes all mailboxes for the account', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertMailbox(repo, id: 'mb', path: 'INBOX');
|
||||||
|
await repo.clearForResync(_account.id);
|
||||||
|
expect(await repo.observeMailboxes(_account.id).first, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Impl under test ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _MailboxRepositoryImplContract extends MailboxRepositoryContract {
|
||||||
|
static const _account = MailboxRepositoryContract._account;
|
||||||
|
|
||||||
|
late AppDatabase _db;
|
||||||
|
late AccountRepositoryImpl _accountRepo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MailboxRepository> makeRepo() async {
|
||||||
|
_db = openTestDatabase();
|
||||||
|
_accountRepo = AccountRepositoryImpl(_db, MapSecureStorage());
|
||||||
|
await _accountRepo.addAccount(_account, 'pw');
|
||||||
|
return MailboxRepositoryImpl(
|
||||||
|
_db,
|
||||||
|
_accountRepo,
|
||||||
|
imapConnect: (_, __, ___) =>
|
||||||
|
Future.error(UnsupportedError('no IMAP in unit tests')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> insertMailbox(
|
||||||
|
MailboxRepository repo, {
|
||||||
|
required String id,
|
||||||
|
required String path,
|
||||||
|
String? role,
|
||||||
|
int unread = 0,
|
||||||
|
int total = 0,
|
||||||
|
}) async {
|
||||||
|
await _db.into(_db.mailboxes).insert(
|
||||||
|
MailboxesCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
accountId: _account.id,
|
||||||
|
path: path,
|
||||||
|
name: path.split('/').last,
|
||||||
|
unreadCount: Value(unread),
|
||||||
|
totalCount: Value(total),
|
||||||
|
role: Value(role),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(configureSqliteForTests);
|
||||||
|
|
||||||
|
group('MailboxRepositoryImpl satisfies MailboxRepository contract', () {
|
||||||
|
_MailboxRepositoryImplContract().run();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user