task check, working again.
This commit is contained in:
@@ -70,11 +70,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install GTK3 and build tools
|
||||
- name: Install GTK3, build tools and libsecret
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev pkg-config cmake ninja-build clang
|
||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
||||
libsecret-1-dev
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
|
||||
@@ -1,10 +1,66 @@
|
||||
# Later
|
||||
|
||||
is there a e2e test tool in Flutter like Playwright? Application should launch, create screenshots...
|
||||
|
||||
---
|
||||
|
||||
How to build Flutter applications in CI?
|
||||
|
||||
Best practices. Build in Container?
|
||||
|
||||
---
|
||||
|
||||
❯ task run
|
||||
Resolving dependencies...
|
||||
Downloading packages...
|
||||
_fe_analyzer_shared 91.0.0 (99.0.0 available)
|
||||
analyzer 8.4.1 (12.1.0 available)
|
||||
characters 1.4.0 (1.4.1 available)
|
||||
dart_style 3.1.3 (3.1.8 available)
|
||||
drift 2.31.0 (2.32.1 available)
|
||||
drift_dev 2.31.0 (2.32.1 available)
|
||||
flutter_lints 4.0.0 (6.0.0 available)
|
||||
flutter_riverpod 2.6.1 (3.3.1 available)
|
||||
flutter_secure_storage 9.2.4 (10.0.0 available)
|
||||
flutter_secure_storage_linux 1.2.3 (3.0.0 available)
|
||||
flutter_secure_storage_macos 3.1.3 (4.0.0 available)
|
||||
flutter_secure_storage_platform_interface 1.1.2 (2.0.1 available)
|
||||
flutter_secure_storage_web 1.2.1 (2.1.0 available)
|
||||
flutter_secure_storage_windows 3.1.2 (4.1.0 available)
|
||||
go_router 14.8.1 (17.2.1 available)
|
||||
js 0.6.7 (0.7.2 available)
|
||||
lints 4.0.0 (6.1.0 available)
|
||||
matcher 0.12.17 (0.12.19 available)
|
||||
material_color_utilities 0.11.1 (0.13.0 available)
|
||||
meta 1.17.0 (1.18.2 available)
|
||||
path_provider_foundation 2.5.1 (2.6.0 available)
|
||||
riverpod 2.6.1 (3.2.1 available)
|
||||
sqlite3 2.9.4 (3.3.1 available)
|
||||
sqlite3_flutter_libs 0.5.42 (0.6.0+eol available)
|
||||
sqlparser 0.43.1 (0.44.3 available)
|
||||
test 1.26.3 (1.31.0 available)
|
||||
test_api 0.7.7 (0.7.11 available)
|
||||
test_core 0.6.12 (0.6.17 available)
|
||||
vector_math 2.2.0 (2.3.0 available)
|
||||
win32 5.15.0 (6.0.1 available)
|
||||
Got dependencies!
|
||||
30 packages have newer versions incompatible with dependency constraints.
|
||||
Try `flutter pub outdated` for more information.
|
||||
|
||||
---
|
||||
|
||||
scripts/check_coverage.dart
|
||||
reduce files in _excluded.
|
||||
|
||||
---
|
||||
|
||||
Renovate: Is there a way to run it outside Github Actions? On cli?
|
||||
|
||||
---
|
||||
|
||||
Write test which fails, when _excluded contains unknown files.
|
||||
---
|
||||
|
||||
Thread view (group by References / In-Reply-To)
|
||||
|
||||
Search (IMAP SEARCH command)
|
||||
|
||||
@@ -74,15 +74,61 @@ task check
|
||||
|
||||
```bash
|
||||
task analyze # flutter analyze (uses analysis_options.yaml)
|
||||
task test # pure-Dart unit tests + coverage gate (≥70%)
|
||||
task test # pure-Dart unit tests + coverage gate (≥85%)
|
||||
task test-widget # widget tests — headless, no device needed
|
||||
task test-flutter # full Flutter test suite (unit + widget + integration)
|
||||
task integration # IMAP/SMTP integration tests via local Stalwart server
|
||||
task build-linux # flutter build linux --debug (compile check)
|
||||
task run # flutter run -d linux
|
||||
task analyze-fix # dart fix --apply
|
||||
```
|
||||
|
||||
`task check` runs `analyze` + `test` in parallel — use it before every commit.
|
||||
`task check` runs `analyze` + `test` + `test-widget` + `build-linux` + `integration` in parallel — use it before every commit.
|
||||
|
||||
### Running the app on desktop in mobile screen resolution
|
||||
|
||||
Start the app on the Linux desktop target:
|
||||
|
||||
```bash
|
||||
task run # or: flutter run -d linux
|
||||
```
|
||||
|
||||
After the window opens, resize it to a phone-like size. Typical reference dimensions:
|
||||
|
||||
| Device profile | Width × Height |
|
||||
| --- | --- |
|
||||
| Compact phone (e.g. Pixel 6a) | 360 × 800 |
|
||||
| Large phone (e.g. iPhone 14 Pro) | 393 × 852 |
|
||||
| Tall phone (e.g. Samsung S24) | 360 × 780 |
|
||||
|
||||
Drag the window border to those dimensions, or use your window manager's "set window size" feature. The Flutter layout engine responds to the window size exactly as it would on a real device — breakpoints, overflow, and scrolling behave identically. Hot-reload (`r` in the terminal) preserves the window size between reloads.
|
||||
|
||||
### Building and installing an Android APK
|
||||
|
||||
Build a release APK with:
|
||||
|
||||
```bash
|
||||
task build-android # or: flutter build apk --release
|
||||
```
|
||||
|
||||
The signed APK is written to:
|
||||
|
||||
```text
|
||||
build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
**Install via ADB** (USB cable or Wi-Fi ADB, device must have "Install from unknown sources" enabled):
|
||||
|
||||
```bash
|
||||
adb install build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
**Install by side-loading** (no cable):
|
||||
|
||||
1. Copy `app-release.apk` to the device (e.g. via USB file transfer, cloud storage, or `adb push`).
|
||||
2. Open a file manager on the device, tap the `.apk` file, and confirm the install prompt.
|
||||
|
||||
> **Tip — split APKs for smaller size:** `flutter build apk --split-per-abi` produces three smaller APKs (one per CPU architecture). Install the one matching the device: `app-arm64-v8a-release.apk` covers almost all modern Android phones.
|
||||
|
||||
### Widget tests
|
||||
|
||||
|
||||
+41
-11
@@ -13,6 +13,13 @@ tasks:
|
||||
- sh: test "${DIRENV_DIR#-}" = "{{.TASKFILE_DIR}}"
|
||||
msg: "Not in nix dev shell. Run: nix develop"
|
||||
|
||||
_pub-get:
|
||||
internal: true
|
||||
run: once
|
||||
deps: [_nix-check]
|
||||
cmds:
|
||||
- flutter pub get --suppress-analytics 2>/dev/null
|
||||
|
||||
install-hooks:
|
||||
desc: Install pre-commit hooks (requires pre-commit; see .pre-commit-config.yaml)
|
||||
cmds:
|
||||
@@ -20,13 +27,13 @@ tasks:
|
||||
|
||||
codegen:
|
||||
desc: Generate Drift DB code (run after any schema change)
|
||||
deps: [_nix-check]
|
||||
deps: [_nix-check, _pub-get]
|
||||
cmds:
|
||||
- flutter pub run build_runner build --delete-conflicting-outputs
|
||||
|
||||
analyze:
|
||||
desc: Static analysis (flutter analyze)
|
||||
deps: [_nix-check]
|
||||
deps: [_nix-check, _pub-get]
|
||||
cmds:
|
||||
- scripts/run_analyze.sh
|
||||
|
||||
@@ -38,15 +45,15 @@ tasks:
|
||||
|
||||
test:
|
||||
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
||||
deps: [_nix-check]
|
||||
deps: [_nix-check, _pub-get]
|
||||
cmds:
|
||||
- scripts/run_unit_tests.sh
|
||||
|
||||
test-widget:
|
||||
desc: Widget tests — headless, no display or network required
|
||||
deps: [_nix-check]
|
||||
deps: [_nix-check, _pub-get]
|
||||
cmds:
|
||||
- flutter test test/widget/
|
||||
- scripts/run_widget_tests.sh
|
||||
|
||||
test-flutter:
|
||||
desc: Full Flutter test suite (unit + widget + integration)
|
||||
@@ -60,18 +67,41 @@ tasks:
|
||||
cmds:
|
||||
- stalwart-dev/test.sh
|
||||
|
||||
_check-libsecret:
|
||||
internal: true
|
||||
cmds:
|
||||
- |
|
||||
if pkg-config --exists libsecret-1; then
|
||||
exit 0
|
||||
elif dpkg -s libsecret-1-dev >/dev/null 2>&1; then
|
||||
echo "Error: libsecret-1-dev is installed but pkg-config cannot find it."
|
||||
echo "Your nix shell was opened before the package was installed."
|
||||
echo "Fix: exit the nix shell and re-enter with: nix develop"
|
||||
exit 1
|
||||
else
|
||||
echo "Error: libsecret-1-dev is not installed."
|
||||
echo "Fix: sudo apt install libsecret-1-dev"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-linux:
|
||||
desc: Build the Linux desktop app (debug)
|
||||
deps: [_nix-check, _pub-get, _check-libsecret]
|
||||
cmds:
|
||||
- flutter build linux --debug --no-pub
|
||||
|
||||
build-android:
|
||||
desc: Build a release APK (output in build/app/outputs/flutter-apk/)
|
||||
deps: [_nix-check]
|
||||
deps: [_nix-check, _pub-get]
|
||||
cmds:
|
||||
- flutter build apk --release
|
||||
- flutter build apk --release --no-pub
|
||||
|
||||
run:
|
||||
desc: Run the app on Linux desktop
|
||||
deps: [_nix-check]
|
||||
deps: [_nix-check, _pub-get]
|
||||
cmds:
|
||||
- flutter run -d linux
|
||||
- flutter run -d linux --no-pub
|
||||
|
||||
check:
|
||||
desc: All fast checks — analyze + unit tests + widget tests + integration in parallel
|
||||
deps: [analyze, test, test-widget, integration]
|
||||
desc: All fast checks — analyze + unit tests + widget tests + build-linux + integration in parallel
|
||||
deps: [analyze, test, test-widget, build-linux, integration]
|
||||
|
||||
Generated
+3
-3
@@ -82,11 +82,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776067740,
|
||||
"narHash": "sha256-B35lpsqnSZwn1Lmz06BpwF7atPgFmUgw1l8KAV3zpVQ=",
|
||||
"lastModified": 1776221942,
|
||||
"narHash": "sha256-FbQAeVNi7G4v3QCSThrSAAvzQTmrmyDLiHNPvTF2qFM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7e495b747b51f95ae15e74377c5ce1fe69c1765f",
|
||||
"rev": "1766437c5509f444c1b15331e82b8b6a9b967000",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -48,6 +48,11 @@
|
||||
libepoxy
|
||||
at-spi2-atk
|
||||
at-spi2-core
|
||||
# libsecret intentionally omitted from Nix inputs: the binary-cache
|
||||
# build of libgpg-error (a transitive dep) requires GLIBC_2.42 which
|
||||
# is absent on non-NixOS hosts (nixpkgs issue, glibc 2.40 vs 2.42).
|
||||
# Use the system package instead:
|
||||
# sudo apt install libsecret-1-dev
|
||||
|
||||
# Local IMAP/SMTP dev server for integration tests
|
||||
stalwart-mail
|
||||
@@ -68,6 +73,19 @@
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||
|
||||
# flutter_secure_storage_linux needs libsecret-1 via pkg-config.
|
||||
# We use the system package (sudo apt install libsecret-1-dev) because
|
||||
# the Nix binary-cache build of libgpg-error (a transitive dep) requires
|
||||
# GLIBC_2.42 which is absent in this closure's glibc 2.40.
|
||||
export PKG_CONFIG_PATH="/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}"
|
||||
|
||||
# Nix sets TMPDIR/TMP/TEMP to a per-shell dir that is removed when the
|
||||
# shell exits. Flutter refuses to start if that dir no longer exists
|
||||
# (https://github.com/flutter/flutter/issues/74042). Reset to /tmp.
|
||||
export TMPDIR=/tmp
|
||||
export TMP=/tmp
|
||||
export TEMP=/tmp
|
||||
|
||||
# Disable Flutter telemetry inside dev shell
|
||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Minimal interface over platform secure credential storage.
|
||||
abstract class SecureStorage {
|
||||
Future<void> write({required String key, required String? value});
|
||||
Future<String?> read({required String key});
|
||||
Future<void> delete({required String key});
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class EmailBodies extends Table {
|
||||
|
||||
@DriftDatabase(tables: [Accounts, Mailboxes, Emails, EmailBodies])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../core/models/account.dart' as model;
|
||||
import '../../core/repositories/account_repository.dart';
|
||||
import '../../core/storage/secure_storage.dart';
|
||||
import '../db/database.dart';
|
||||
|
||||
class AccountRepositoryImpl implements AccountRepository {
|
||||
AccountRepositoryImpl(this._db);
|
||||
AccountRepositoryImpl(this._db, this._storage);
|
||||
|
||||
final AppDatabase _db;
|
||||
final _storage = const FlutterSecureStorage();
|
||||
final SecureStorage _storage;
|
||||
|
||||
@override
|
||||
Stream<List<model.Account>> observeAccounts() {
|
||||
|
||||
@@ -3,17 +3,31 @@ import 'dart:convert';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
|
||||
import '../../core/models/account.dart' as account_model;
|
||||
import '../../core/models/email.dart' as model;
|
||||
import '../../core/repositories/account_repository.dart';
|
||||
import '../../core/repositories/email_repository.dart';
|
||||
import '../db/database.dart';
|
||||
import '../imap/imap_client_factory.dart';
|
||||
|
||||
typedef ImapConnectFn = Future<imap.ImapClient> Function(
|
||||
account_model.Account account, String password);
|
||||
typedef SmtpConnectFn = Future<imap.SmtpClient> Function(
|
||||
account_model.Account account, String password);
|
||||
|
||||
class EmailRepositoryImpl implements EmailRepository {
|
||||
EmailRepositoryImpl(this._db, this._accounts);
|
||||
EmailRepositoryImpl(
|
||||
this._db,
|
||||
this._accounts, {
|
||||
ImapConnectFn imapConnect = connectImap,
|
||||
SmtpConnectFn smtpConnect = connectSmtp,
|
||||
}) : _imapConnect = imapConnect,
|
||||
_smtpConnect = smtpConnect;
|
||||
|
||||
final AppDatabase _db;
|
||||
final AccountRepository _accounts;
|
||||
final ImapConnectFn _imapConnect;
|
||||
final SmtpConnectFn _smtpConnect;
|
||||
|
||||
// ── Observe ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,7 +69,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(emailRow.accountId))!;
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
final client = await connectImap(account, password);
|
||||
final client = await _imapConnect(account, password);
|
||||
try {
|
||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY[])');
|
||||
@@ -101,7 +115,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<void> syncEmails(String accountId, String mailboxPath) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final password = await _accounts.getPassword(accountId);
|
||||
final client = await connectImap(account, password);
|
||||
final client = await _imapConnect(account, password);
|
||||
try {
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
final fetch = await client.fetchMessages(
|
||||
@@ -151,7 +165,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
final client = await connectImap(account, password);
|
||||
final client = await _imapConnect(account, password);
|
||||
try {
|
||||
await client.selectMailboxByPath(row.mailboxPath);
|
||||
final seq = imap.MessageSequence.fromId(row.uid, isUid: true);
|
||||
@@ -184,7 +198,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
final client = await connectImap(account, password);
|
||||
final client = await _imapConnect(account, password);
|
||||
try {
|
||||
await client.selectMailboxByPath(row.mailboxPath);
|
||||
await client.uidMove(
|
||||
@@ -204,7 +218,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
final client = await connectImap(account, password);
|
||||
final client = await _imapConnect(account, password);
|
||||
try {
|
||||
await client.selectMailboxByPath(row.mailboxPath);
|
||||
final seq = imap.MessageSequence.fromId(row.uid, isUid: true);
|
||||
@@ -220,7 +234,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<void> sendEmail(String accountId, model.EmailDraft draft) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final password = await _accounts.getPassword(accountId);
|
||||
final smtpClient = await connectSmtp(account, password);
|
||||
final smtpClient = await _smtpConnect(account, password);
|
||||
try {
|
||||
final builder = imap.MessageBuilder()
|
||||
..from = [imap.MailAddress(draft.from.name, draft.from.email)]
|
||||
@@ -246,7 +260,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final password = await _accounts.getPassword(accountId);
|
||||
final client = await connectImap(account, password);
|
||||
final client = await _imapConnect(account, password);
|
||||
try {
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
final escaped = query.replaceAll('"', '\\"');
|
||||
|
||||
@@ -7,12 +7,18 @@ import '../../core/repositories/mailbox_repository.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../db/database.dart';
|
||||
import '../imap/imap_client_factory.dart';
|
||||
import 'email_repository_impl.dart' show ImapConnectFn;
|
||||
|
||||
class MailboxRepositoryImpl implements MailboxRepository {
|
||||
MailboxRepositoryImpl(this._db, this._accounts);
|
||||
MailboxRepositoryImpl(
|
||||
this._db,
|
||||
this._accounts, {
|
||||
ImapConnectFn imapConnect = connectImap,
|
||||
}) : _imapConnect = imapConnect;
|
||||
|
||||
final AppDatabase _db;
|
||||
final AccountRepository _accounts;
|
||||
final ImapConnectFn _imapConnect;
|
||||
|
||||
@override
|
||||
Stream<List<model.Mailbox>> observeMailboxes(String accountId) {
|
||||
@@ -27,7 +33,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
Future<void> syncMailboxes(String accountId) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final password = await _accounts.getPassword(accountId);
|
||||
final client = await connectImap(account, password);
|
||||
final client = await _imapConnect(account, password);
|
||||
try {
|
||||
final mailboxes = await client.listMailboxes(recursive: true);
|
||||
for (final mb in mailboxes) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../core/storage/secure_storage.dart';
|
||||
|
||||
class FlutterSecureStorageImpl implements SecureStorage {
|
||||
const FlutterSecureStorageImpl();
|
||||
|
||||
static const _impl = FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String? value}) =>
|
||||
_impl.write(key: key, value: value);
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) => _impl.read(key: key);
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) => _impl.delete(key: key);
|
||||
}
|
||||
+5
-1
@@ -8,6 +8,7 @@ import 'data/db/database.dart';
|
||||
import 'data/repositories/account_repository_impl.dart';
|
||||
import 'data/repositories/email_repository_impl.dart';
|
||||
import 'data/repositories/mailbox_repository_impl.dart';
|
||||
import 'data/storage/flutter_secure_storage_impl.dart';
|
||||
|
||||
final dbProvider = Provider<AppDatabase>((ref) {
|
||||
final db = AppDatabase();
|
||||
@@ -16,7 +17,10 @@ final dbProvider = Provider<AppDatabase>((ref) {
|
||||
});
|
||||
|
||||
final accountRepositoryProvider = Provider<AccountRepository>((ref) {
|
||||
return AccountRepositoryImpl(ref.watch(dbProvider));
|
||||
return AccountRepositoryImpl(
|
||||
ref.watch(dbProvider),
|
||||
const FlutterSecureStorageImpl(),
|
||||
);
|
||||
});
|
||||
|
||||
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
||||
|
||||
+91
-12
@@ -1,21 +1,48 @@
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(sharedinbox LANGUAGES CXX)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
set(BINARY_NAME "sharedinbox")
|
||||
set(APPLICATION_ID "de.sharedinbox.sharedinbox")
|
||||
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
# Load bundled libraries from the lib/ directory relative to the binary.
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Configure the Flutter app plugin list — generated by flutter pub get.
|
||||
include(flutter/generated_plugins.cmake OPTIONAL RESULT_VARIABLE HAVE_PLUGINS)
|
||||
# Build mode defaults.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings applied to the runner and all plugins.
|
||||
function(apply_standard_settings TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
# flutter_secure_storage_linux bundles json.hpp which uses a deprecated
|
||||
# user-defined-literal syntax that triggers -Wdeprecated-literal-operator.
|
||||
# Suppress it here because we cannot modify the vendored header.
|
||||
target_compile_options(${TARGET} PRIVATE -Wno-deprecated-literal-operator)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR} managed_flutter)
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR} managed_flutter)
|
||||
|
||||
add_executable(${BINARY_NAME} "main.cc" "my_application.cc")
|
||||
# Application executable.
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
)
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION="${FLUTTER_VERSION}")
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR})
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR})
|
||||
@@ -23,12 +50,64 @@ target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION_PATCH=${FLUTTE
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter PkgConfig::GTK)
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
# nixos-25.11 packages are compiled against glibc 2.42 from the Nix toolchain.
|
||||
# On non-NixOS hosts with an older glibc (e.g. 2.40) the static linker cannot
|
||||
# resolve versioned symbols such as __inet_pton_chk@GLIBC_2.42 that live in
|
||||
# Nix-built transitive dependencies (libgpg-error → libsecret →
|
||||
# flutter_secure_storage_linux). Those .so files carry their own RPATH to the
|
||||
# Nix glibc, so the symbols ARE present at runtime — the link-time check is a
|
||||
# false alarm. --allow-shlib-undefined suppresses it.
|
||||
target_link_options(${BINARY_NAME} PRIVATE -Wl,--allow-shlib-undefined)
|
||||
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
# Put the unbundled binary in a subdirectory so it is not accidentally run
|
||||
# without the required assets alongside it.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
# Generated plugin build rules.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
# === Installation ===
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "Install prefix" FORCE)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
apply_flutter_bundle_properties(${BINARY_NAME}
|
||||
BUNDLE_ID "de.sharedinbox.sharedinbox"
|
||||
BUNDLE_EXECUTABLE "${BINARY_NAME}"
|
||||
)
|
||||
install(CODE "file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")" COMPONENT Runtime)
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endforeach(bundled_library)
|
||||
|
||||
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
|
||||
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# This file controls Flutter-level build steps. It should not be edited.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
|
||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||
# which isn't available in 3.10.
|
||||
function(list_prepend LIST_NAME PREFIX)
|
||||
set(NEW_LIST "")
|
||||
foreach(element ${${LIST_NAME}})
|
||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||
endforeach(element)
|
||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# === Flutter Library ===
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"fl_basic_message_channel.h"
|
||||
"fl_binary_codec.h"
|
||||
"fl_binary_messenger.h"
|
||||
"fl_dart_project.h"
|
||||
"fl_engine.h"
|
||||
"fl_json_message_codec.h"
|
||||
"fl_json_method_codec.h"
|
||||
"fl_message_codec.h"
|
||||
"fl_method_call.h"
|
||||
"fl_method_channel.h"
|
||||
"fl_method_codec.h"
|
||||
"fl_method_response.h"
|
||||
"fl_plugin_registrar.h"
|
||||
"fl_plugin_registry.h"
|
||||
"fl_standard_message_codec.h"
|
||||
"fl_standard_method_codec.h"
|
||||
"fl_string_codec.h"
|
||||
"fl_value.h"
|
||||
"fl_view.h"
|
||||
"flutter_linux.h"
|
||||
)
|
||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||
target_link_libraries(flutter INTERFACE
|
||||
PkgConfig::GTK
|
||||
PkgConfig::GLIB
|
||||
PkgConfig::GIO
|
||||
)
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
)
|
||||
@@ -34,7 +34,7 @@ static void my_application_activate(GApplication* application) {
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
static void my_application_local_command_line(GApplication* application,
|
||||
static gboolean my_application_local_command_line(GApplication* application,
|
||||
gchar*** arguments,
|
||||
int* exit_status) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
@@ -45,11 +45,12 @@ static void my_application_local_command_line(GApplication* application,
|
||||
if (!g_application_register(application, nullptr, &error)) {
|
||||
g_warning("Failed to register: %s", error->message);
|
||||
*exit_status = 1;
|
||||
return;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
g_application_activate(application);
|
||||
*exit_status = 0;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void my_application_dispose(GObject* object) {
|
||||
|
||||
@@ -39,6 +39,7 @@ dev_dependencies:
|
||||
build_runner: ^2.4.13
|
||||
test: ^1.25.0
|
||||
fake_async: ^1.3.1
|
||||
sqlite3: any # used directly in test/unit/db_test_helper.dart
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -14,17 +14,24 @@ const _noCode = {
|
||||
'lib/core/repositories/account_repository.dart',
|
||||
'lib/core/repositories/email_repository.dart',
|
||||
'lib/core/repositories/mailbox_repository.dart',
|
||||
'lib/core/storage/secure_storage.dart',
|
||||
};
|
||||
|
||||
// Files excluded from the unit-coverage gate because they require integration
|
||||
// or widget tests (covered by `task integration` / `task test-flutter`).
|
||||
const _excluded = {
|
||||
// Data layer — requires Drift/SQLite, IMAP/SMTP network connections.
|
||||
// Drift table schema DSL + database factory — the column getters (e.g.
|
||||
// `TextColumn get id => text()()`) are build-time input to Drift's code
|
||||
// generator and are never called at runtime. The `_openConnection()`
|
||||
// factory uses `path_provider` which is unavailable in unit tests.
|
||||
'lib/data/db/database.dart',
|
||||
// IMAP/SMTP factory — top-level functions that open real network connections;
|
||||
// no seam to inject a fake client without wrapping the enough_mail types.
|
||||
'lib/data/imap/imap_client_factory.dart',
|
||||
'lib/data/repositories/account_repository_impl.dart',
|
||||
'lib/data/repositories/email_repository_impl.dart',
|
||||
'lib/data/repositories/mailbox_repository_impl.dart',
|
||||
// Pure adapter over FlutterSecureStorage (a platform plugin);
|
||||
// all three methods just delegate — no logic, and platform channels are
|
||||
// unavailable in unit tests.
|
||||
'lib/data/storage/flutter_secure_storage_impl.dart',
|
||||
// Flutter wiring — requires full widget/app context.
|
||||
'lib/di.dart',
|
||||
'lib/main.dart',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
START=$(date +%s)
|
||||
flutter analyze
|
||||
flutter analyze --no-pub
|
||||
END=$(date +%s)
|
||||
echo "analyze: $((END - START))s"
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
START=$(date +%s)
|
||||
flutter test test/unit/ test/widget/ --coverage
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
if flutter test test/unit/ test/widget/ --coverage --no-pub --reporter expanded >"$tmp" 2>&1; then
|
||||
# Success: show only the summary line
|
||||
grep -E "^All [0-9]+ tests passed" "$tmp" || tail -1 "$tmp"
|
||||
else
|
||||
# Failure: show the full output so the developer sees what broke
|
||||
cat "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
dart run scripts/check_coverage.dart
|
||||
END=$(date +%s)
|
||||
echo "test: $((END - START))s"
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
START=$(date +%s)
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
if flutter test test/widget/ --no-pub --reporter expanded >"$tmp" 2>&1; then
|
||||
grep -E "^All [0-9]+ tests passed" "$tmp" || tail -1 "$tmp"
|
||||
else
|
||||
cat "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
END=$(date +%s)
|
||||
echo "test-widget: $((END - START))s"
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
|
||||
import 'db_test_helper.dart';
|
||||
|
||||
// ── Fake ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class MapSecureStorage implements SecureStorage {
|
||||
final _map = <String, String>{};
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String? value}) async {
|
||||
if (value == null) {
|
||||
_map.remove(key);
|
||||
} else {
|
||||
_map[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) async => _map[key];
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) async => _map.remove(key);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const _account = Account(
|
||||
id: 'acc-1',
|
||||
displayName: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
imapPort: 993,
|
||||
imapSsl: true,
|
||||
smtpHost: 'smtp.example.com',
|
||||
smtpPort: 587,
|
||||
smtpSsl: false,
|
||||
);
|
||||
|
||||
AccountRepositoryImpl _makeRepo() =>
|
||||
AccountRepositoryImpl(openTestDatabase(), MapSecureStorage());
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
setUpAll(configureSqliteForTests);
|
||||
|
||||
group('AccountRepositoryImpl', () {
|
||||
test('observeAccounts emits empty list initially', () async {
|
||||
final repo = _makeRepo();
|
||||
final accounts = await repo.observeAccounts().first;
|
||||
expect(accounts, isEmpty);
|
||||
});
|
||||
|
||||
test('addAccount then observeAccounts emits the account', () async {
|
||||
final repo = _makeRepo();
|
||||
await repo.addAccount(_account, 'secret');
|
||||
final accounts = await repo.observeAccounts().first;
|
||||
expect(accounts, hasLength(1));
|
||||
expect(accounts.first.id, 'acc-1');
|
||||
expect(accounts.first.email, 'alice@example.com');
|
||||
});
|
||||
|
||||
test('getAccount returns null for unknown id', () async {
|
||||
final repo = _makeRepo();
|
||||
expect(await repo.getAccount('unknown'), isNull);
|
||||
});
|
||||
|
||||
test('getAccount returns the account after addAccount', () async {
|
||||
final repo = _makeRepo();
|
||||
await repo.addAccount(_account, 'secret');
|
||||
final result = await repo.getAccount('acc-1');
|
||||
expect(result, isNotNull);
|
||||
expect(result!.displayName, 'Alice');
|
||||
});
|
||||
|
||||
test('getPassword returns stored password', () async {
|
||||
final repo = _makeRepo();
|
||||
await repo.addAccount(_account, 'mypassword');
|
||||
expect(await repo.getPassword('acc-1'), 'mypassword');
|
||||
});
|
||||
|
||||
test('getPassword throws StateError when no password stored', () async {
|
||||
final repo = _makeRepo();
|
||||
expect(
|
||||
() => repo.getPassword('missing'),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('removeAccount deletes account and password', () async {
|
||||
final repo = _makeRepo();
|
||||
await repo.addAccount(_account, 'secret');
|
||||
await repo.removeAccount('acc-1');
|
||||
|
||||
expect(await repo.getAccount('acc-1'), isNull);
|
||||
expect(
|
||||
() => repo.getPassword('acc-1'),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAccount is idempotent (upsert)', () async {
|
||||
final repo = _makeRepo();
|
||||
await repo.addAccount(_account, 'v1');
|
||||
const updated = Account(
|
||||
id: 'acc-1',
|
||||
displayName: 'Alice Updated',
|
||||
email: 'alice@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
imapPort: 993,
|
||||
imapSsl: true,
|
||||
smtpHost: 'smtp.example.com',
|
||||
smtpPort: 587,
|
||||
smtpSsl: false,
|
||||
);
|
||||
await repo.addAccount(updated, 'v2');
|
||||
final accounts = await repo.observeAccounts().first;
|
||||
expect(accounts, hasLength(1));
|
||||
expect(accounts.first.displayName, 'Alice Updated');
|
||||
expect(await repo.getPassword('acc-1'), 'v2');
|
||||
});
|
||||
|
||||
test('observeAccounts reflects removal', () async {
|
||||
final repo = _makeRepo();
|
||||
await repo.addAccount(_account, 'secret');
|
||||
await repo.removeAccount('acc-1');
|
||||
final accounts = await repo.observeAccounts().first;
|
||||
expect(accounts, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:sqlite3/open.dart';
|
||||
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
|
||||
/// Call once per test file (e.g. in setUpAll) before creating any AppDatabase.
|
||||
void configureSqliteForTests() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
if (Platform.isLinux) {
|
||||
open.overrideFor(
|
||||
OperatingSystem.linux,
|
||||
() => DynamicLibrary.open('/usr/lib/x86_64-linux-gnu/libsqlite3.so.0'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AppDatabase openTestDatabase() => AppDatabase(NativeDatabase.memory());
|
||||
@@ -0,0 +1,437 @@
|
||||
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/models/email.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';
|
||||
import 'fake_imap.dart';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const _account = Account(
|
||||
id: 'acc-1',
|
||||
displayName: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
imapPort: 993,
|
||||
imapSsl: true,
|
||||
smtpHost: 'smtp.example.com',
|
||||
smtpPort: 587,
|
||||
smtpSsl: false,
|
||||
);
|
||||
|
||||
Future<imap.ImapClient> _noImapConnect(Account a, String p) =>
|
||||
Future.error(UnsupportedError('IMAP unavailable in unit tests'));
|
||||
|
||||
Future<imap.SmtpClient> _noSmtpConnect(Account a, String p) =>
|
||||
Future.error(UnsupportedError('SMTP unavailable in unit tests'));
|
||||
|
||||
({
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
EmailRepositoryImpl emails,
|
||||
}) _makeRepos() {
|
||||
final db = openTestDatabase();
|
||||
final storage = MapSecureStorage();
|
||||
final accounts = AccountRepositoryImpl(db, storage);
|
||||
final emails = EmailRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: _noImapConnect,
|
||||
smtpConnect: _noSmtpConnect,
|
||||
);
|
||||
return (db: db, accounts: accounts, emails: emails);
|
||||
}
|
||||
|
||||
({
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
EmailRepositoryImpl emails,
|
||||
FakeImapClient fakeImap,
|
||||
FakeSmtpClient fakeSmtp,
|
||||
}) _makeReposWithFakes() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final fakeImap = FakeImapClient();
|
||||
final fakeSmtp = FakeSmtpClient();
|
||||
final emails = EmailRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: (_, __) async => fakeImap,
|
||||
smtpConnect: (_, __) async => fakeSmtp,
|
||||
);
|
||||
return (
|
||||
db: db,
|
||||
accounts: accounts,
|
||||
emails: emails,
|
||||
fakeImap: fakeImap,
|
||||
fakeSmtp: fakeSmtp,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
setUpAll(configureSqliteForTests);
|
||||
|
||||
group('EmailRepositoryImpl', () {
|
||||
test('observeEmails emits empty list when no emails', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
final emails =
|
||||
await r.emails.observeEmails('acc-1', 'INBOX').first;
|
||||
expect(emails, isEmpty);
|
||||
});
|
||||
|
||||
test('getEmail returns null for unknown id', () async {
|
||||
final r = _makeRepos();
|
||||
expect(await r.emails.getEmail('no-such-id'), isNull);
|
||||
});
|
||||
|
||||
test('observeEmails reflects inserted row', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:42',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 42,
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
final emails = await r.emails.observeEmails('acc-1', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.id, 'acc-1:42');
|
||||
expect(emails.first.uid, 42);
|
||||
});
|
||||
|
||||
test('getEmail returns inserted row', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:7',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 7,
|
||||
receivedAt: DateTime(2024, 6, 15),
|
||||
),
|
||||
);
|
||||
|
||||
final email = await r.emails.getEmail('acc-1:7');
|
||||
expect(email, isNotNull);
|
||||
expect(email!.mailboxPath, 'INBOX');
|
||||
});
|
||||
|
||||
test('observeEmails orders by receivedAt descending', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
for (final (uid, date) in [
|
||||
(1, DateTime(2024)),
|
||||
(3, DateTime(2024, 3)),
|
||||
(2, DateTime(2024, 2)),
|
||||
]) {
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:$uid',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: uid,
|
||||
receivedAt: date,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final emails = await r.emails.observeEmails('acc-1', 'INBOX').first;
|
||||
expect(emails.map((e) => e.uid).toList(), [3, 2, 1]);
|
||||
});
|
||||
|
||||
test('syncEmails propagates IMAP error', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
expect(
|
||||
() => r.emails.syncEmails('acc-1', 'INBOX'),
|
||||
throwsA(isA<UnsupportedError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('getEmailBody propagates IMAP error when not cached', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
() => r.emails.getEmailBody('acc-1:1'),
|
||||
throwsA(isA<UnsupportedError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('getEmailBody returns cached body without IMAP call', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emailBodies).insert(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: 'acc-1:1',
|
||||
textBody: const Value('Hello'),
|
||||
htmlBody: const Value('<p>Hello</p>'),
|
||||
),
|
||||
);
|
||||
|
||||
final body = await r.emails.getEmailBody('acc-1:1');
|
||||
expect(body.textBody, 'Hello');
|
||||
expect(body.htmlBody, '<p>Hello</p>');
|
||||
});
|
||||
|
||||
test('getEmailBody fetches from IMAP and caches when not stored', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
receivedAt: DateTime(2024),
|
||||
));
|
||||
|
||||
// Build a simple text/plain MimeMessage the IMAP fake will return.
|
||||
final msg = imap.MimeMessage.parseFromText(
|
||||
'Subject: Hi\r\n'
|
||||
'Content-Type: text/plain\r\n'
|
||||
'\r\n'
|
||||
'Hello from IMAP',
|
||||
);
|
||||
msg.uid = 1;
|
||||
r.fakeImap.fetchResults = [msg];
|
||||
|
||||
final body = await r.emails.getEmailBody('acc-1:1');
|
||||
|
||||
expect(body.textBody, contains('Hello from IMAP'));
|
||||
expect(r.fakeImap.logoutCalled, isTrue);
|
||||
|
||||
// Second call should return cached without IMAP.
|
||||
r.fakeImap.logoutCalled = false;
|
||||
final cached = await r.emails.getEmailBody('acc-1:1');
|
||||
expect(cached.textBody, body.textBody);
|
||||
expect(r.fakeImap.logoutCalled, isFalse);
|
||||
});
|
||||
|
||||
// ── IMAP method tests ────────────────────────────────────────────────────
|
||||
|
||||
test('syncEmails stores fetched messages in DB', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
r.fakeImap.fetchResults = [
|
||||
buildEnvelopeMessage(uid: 10, subject: 'Hello'),
|
||||
buildEnvelopeMessage(uid: 11, subject: 'World', flags: [r'\Seen']),
|
||||
];
|
||||
|
||||
await r.emails.syncEmails('acc-1', 'INBOX');
|
||||
|
||||
final emails =
|
||||
await r.emails.observeEmails('acc-1', 'INBOX').first;
|
||||
expect(emails, hasLength(2));
|
||||
expect(emails.map((e) => e.uid).toSet(), {10, 11});
|
||||
expect(emails.firstWhere((e) => e.uid == 11).isSeen, isTrue);
|
||||
expect(r.fakeImap.logoutCalled, isTrue);
|
||||
});
|
||||
|
||||
test('setFlag seen=true calls uidMarkSeen and updates DB', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
));
|
||||
|
||||
await r.emails.setFlag('acc-1:5', seen: true);
|
||||
|
||||
expect(r.fakeImap.markSeenCalls, 1);
|
||||
expect(r.fakeImap.markUnseenCalls, 0);
|
||||
final email = await r.emails.getEmail('acc-1:5');
|
||||
expect(email!.isSeen, isTrue);
|
||||
expect(r.fakeImap.logoutCalled, isTrue);
|
||||
});
|
||||
|
||||
test('setFlag seen=false calls uidMarkUnseen', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
));
|
||||
|
||||
await r.emails.setFlag('acc-1:5', seen: false);
|
||||
|
||||
expect(r.fakeImap.markUnseenCalls, 1);
|
||||
final email = await r.emails.getEmail('acc-1:5');
|
||||
expect(email!.isSeen, isFalse);
|
||||
});
|
||||
|
||||
test('setFlag flagged=true calls uidMarkFlagged', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
));
|
||||
|
||||
await r.emails.setFlag('acc-1:5', flagged: true);
|
||||
|
||||
expect(r.fakeImap.markFlaggedCalls, 1);
|
||||
final email = await r.emails.getEmail('acc-1:5');
|
||||
expect(email!.isFlagged, isTrue);
|
||||
});
|
||||
|
||||
test('setFlag flagged=false calls uidMarkUnflagged', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
));
|
||||
|
||||
await r.emails.setFlag('acc-1:5', flagged: false);
|
||||
|
||||
expect(r.fakeImap.markUnflaggedCalls, 1);
|
||||
});
|
||||
|
||||
test('moveEmail removes email from DB and calls uidMove', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
));
|
||||
|
||||
await r.emails.moveEmail('acc-1:5', 'Archive');
|
||||
|
||||
expect(r.fakeImap.moveEmailCalls, 1);
|
||||
expect(await r.emails.getEmail('acc-1:5'), isNull);
|
||||
expect(r.fakeImap.logoutCalled, isTrue);
|
||||
});
|
||||
|
||||
test('deleteEmail removes email from DB and marks deleted', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
));
|
||||
|
||||
await r.emails.deleteEmail('acc-1:5');
|
||||
|
||||
expect(r.fakeImap.markDeletedCalls, 1);
|
||||
expect(r.fakeImap.expungeCalls, 1);
|
||||
expect(await r.emails.getEmail('acc-1:5'), isNull);
|
||||
expect(r.fakeImap.logoutCalled, isTrue);
|
||||
});
|
||||
|
||||
test('sendEmail calls SMTP sendMessage and quit', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.emails.sendEmail(
|
||||
'acc-1',
|
||||
const EmailDraft(
|
||||
from: EmailAddress(name: 'Alice', email: 'alice@example.com'),
|
||||
to: [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
cc: [],
|
||||
subject: 'Hello',
|
||||
body: 'Hi Bob',
|
||||
),
|
||||
);
|
||||
|
||||
expect(r.fakeSmtp.messageSent, isTrue);
|
||||
expect(r.fakeSmtp.quitCalled, isTrue);
|
||||
});
|
||||
|
||||
test('searchEmails returns emails matching IMAP search', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
r.fakeImap.searchUids = [7, 8];
|
||||
r.fakeImap.fetchResults = [
|
||||
buildEnvelopeMessage(uid: 7, subject: 'Result A'),
|
||||
buildEnvelopeMessage(uid: 8, subject: 'Result B'),
|
||||
];
|
||||
|
||||
final results =
|
||||
await r.emails.searchEmails('acc-1', 'INBOX', 'result');
|
||||
|
||||
expect(results, hasLength(2));
|
||||
expect(results.map((e) => e.subject).toSet(), {'Result A', 'Result B'});
|
||||
expect(r.fakeImap.logoutCalled, isTrue);
|
||||
});
|
||||
|
||||
test('searchEmails returns empty list when no UIDs match', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
r.fakeImap.searchUids = [];
|
||||
|
||||
final results =
|
||||
await r.emails.searchEmails('acc-1', 'INBOX', 'nothing');
|
||||
|
||||
expect(results, isEmpty);
|
||||
});
|
||||
|
||||
test('syncEmails skips messages with no envelope or no uid', () async {
|
||||
final r = _makeReposWithFakes();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
r.fakeImap.fetchResults = [
|
||||
buildMessageWithoutEnvelope(), // no envelope → skip
|
||||
buildEnvelopeMessage(uid: 42, subject: 'Valid'),
|
||||
];
|
||||
|
||||
await r.emails.syncEmails('acc-1', 'INBOX');
|
||||
|
||||
final emails =
|
||||
await r.emails.observeEmails('acc-1', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.uid, 42);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
|
||||
/// Configurable fake IMAP client that extends the real ImapClient but
|
||||
/// overrides every network method to return pre-set data.
|
||||
class FakeImapClient extends imap.ImapClient {
|
||||
FakeImapClient() : super();
|
||||
|
||||
List<imap.MimeMessage> fetchResults = [];
|
||||
List<imap.Mailbox> listMailboxesResult = [];
|
||||
List<int> searchUids = [];
|
||||
bool logoutCalled = false;
|
||||
bool throwOnStatus = false;
|
||||
int markSeenCalls = 0;
|
||||
int markUnseenCalls = 0;
|
||||
int markFlaggedCalls = 0;
|
||||
int markUnflaggedCalls = 0;
|
||||
int markDeletedCalls = 0;
|
||||
int expungeCalls = 0;
|
||||
int moveEmailCalls = 0;
|
||||
|
||||
@override
|
||||
Future<imap.Mailbox> selectMailboxByPath(
|
||||
String path, {
|
||||
bool enableCondStore = false,
|
||||
imap.QResyncParameters? qresync,
|
||||
}) async =>
|
||||
imap.Mailbox(
|
||||
encodedName: path,
|
||||
encodedPath: path,
|
||||
flags: [],
|
||||
pathSeparator: '/',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<imap.FetchImapResult> fetchMessages(
|
||||
imap.MessageSequence sequence,
|
||||
String? fetchContentDefinition, {
|
||||
int? changedSinceModSequence,
|
||||
Duration? responseTimeout,
|
||||
}) async =>
|
||||
imap.FetchImapResult(List.of(fetchResults), null);
|
||||
|
||||
@override
|
||||
Future<imap.FetchImapResult> uidFetchMessage(
|
||||
int messageUid,
|
||||
String fetchContentDefinition, {
|
||||
Duration? responseTimeout,
|
||||
}) async =>
|
||||
imap.FetchImapResult(
|
||||
fetchResults.isEmpty ? [] : [fetchResults.first],
|
||||
null,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<imap.StoreImapResult> uidMarkSeen(
|
||||
imap.MessageSequence sequence, {
|
||||
bool? silent,
|
||||
int? unchangedSinceModSequence,
|
||||
}) async {
|
||||
markSeenCalls++;
|
||||
return imap.StoreImapResult();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<imap.StoreImapResult> uidMarkUnseen(
|
||||
imap.MessageSequence sequence, {
|
||||
bool? silent,
|
||||
int? unchangedSinceModSequence,
|
||||
}) async {
|
||||
markUnseenCalls++;
|
||||
return imap.StoreImapResult();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<imap.StoreImapResult> uidMarkFlagged(
|
||||
imap.MessageSequence sequence, {
|
||||
bool? silent,
|
||||
int? unchangedSinceModSequence,
|
||||
}) async {
|
||||
markFlaggedCalls++;
|
||||
return imap.StoreImapResult();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<imap.StoreImapResult> uidMarkUnflagged(
|
||||
imap.MessageSequence sequence, {
|
||||
bool? silent,
|
||||
int? unchangedSinceModSequence,
|
||||
}) async {
|
||||
markUnflaggedCalls++;
|
||||
return imap.StoreImapResult();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<imap.StoreImapResult> uidMarkDeleted(
|
||||
imap.MessageSequence sequence, {
|
||||
bool? silent,
|
||||
int? unchangedSinceModSequence,
|
||||
}) async {
|
||||
markDeletedCalls++;
|
||||
return imap.StoreImapResult();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<imap.Mailbox?> uidExpunge(imap.MessageSequence sequence) async {
|
||||
expungeCalls++;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<imap.GenericImapResult> uidMove(
|
||||
imap.MessageSequence sequence, {
|
||||
imap.Mailbox? targetMailbox,
|
||||
String? targetMailboxPath,
|
||||
}) async {
|
||||
moveEmailCalls++;
|
||||
return imap.GenericImapResult();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<imap.SearchImapResult> uidSearchMessages({
|
||||
String searchCriteria = 'UNSEEN',
|
||||
List<imap.ReturnOption>? returnOptions,
|
||||
Duration? responseTimeout,
|
||||
}) async {
|
||||
final result = imap.SearchImapResult();
|
||||
if (searchUids.isNotEmpty) {
|
||||
result.matchingSequence =
|
||||
imap.MessageSequence.fromIds(searchUids, isUid: true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<imap.Mailbox>> listMailboxes({
|
||||
String path = '""',
|
||||
bool recursive = false,
|
||||
List<String>? mailboxPatterns,
|
||||
List<String>? selectionOptions,
|
||||
List<imap.ReturnOption>? returnOptions,
|
||||
}) async =>
|
||||
List.of(listMailboxesResult);
|
||||
|
||||
@override
|
||||
Future<imap.Mailbox> statusMailbox(
|
||||
imap.Mailbox box,
|
||||
List<imap.StatusFlags> flags,
|
||||
) async {
|
||||
if (throwOnStatus) throw Exception('STATUS not supported');
|
||||
return imap.Mailbox(
|
||||
encodedName: box.encodedName,
|
||||
encodedPath: box.encodedPath,
|
||||
flags: [],
|
||||
pathSeparator: '/',
|
||||
messagesUnseen: 3,
|
||||
messagesExists: 10,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> logout() async {
|
||||
logoutCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Configurable fake SMTP client.
|
||||
class FakeSmtpClient extends imap.SmtpClient {
|
||||
FakeSmtpClient() : super('fake.domain');
|
||||
|
||||
bool messageSent = false;
|
||||
bool quitCalled = false;
|
||||
|
||||
@override
|
||||
Future<imap.SmtpResponse> sendMessage(
|
||||
imap.MimeMessage message, {
|
||||
bool use8BitEncoding = false,
|
||||
imap.MailAddress? from,
|
||||
List<imap.MailAddress>? recipients,
|
||||
}) async {
|
||||
messageSent = true;
|
||||
return imap.SmtpResponse(['250 OK']);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<imap.SmtpResponse> quit() async {
|
||||
quitCalled = true;
|
||||
return imap.SmtpResponse(['221 Bye']);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a [MimeMessage] with no envelope (simulates a malformed FETCH row
|
||||
/// that should be skipped by the repository).
|
||||
imap.MimeMessage buildMessageWithoutEnvelope() => imap.MimeMessage()..uid = 99;
|
||||
|
||||
/// Builds a [MimeMessage] that looks like an ENVELOPE fetch result.
|
||||
imap.MimeMessage buildEnvelopeMessage({
|
||||
required int uid,
|
||||
String subject = 'Test Subject',
|
||||
DateTime? date,
|
||||
String fromEmail = 'sender@example.com',
|
||||
List<String> flags = const [],
|
||||
}) {
|
||||
final envelope = imap.Envelope(
|
||||
subject: subject,
|
||||
date: date ?? DateTime(2024, 1, 15),
|
||||
from: [imap.MailAddress('Sender', fromEmail)],
|
||||
to: [const imap.MailAddress('Recipient', 'recipient@example.com')],
|
||||
cc: [],
|
||||
);
|
||||
return imap.MimeMessage.fromEnvelope(
|
||||
envelope,
|
||||
uid: uid,
|
||||
flags: List.of(flags),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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/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',
|
||||
imapPort: 993,
|
||||
imapSsl: true,
|
||||
smtpHost: 'smtp.example.com',
|
||||
smtpPort: 587,
|
||||
smtpSsl: false,
|
||||
);
|
||||
|
||||
Future<imap.ImapClient> _noImapConnect(Account a, String p) =>
|
||||
Future.error(UnsupportedError('IMAP unavailable in unit tests'));
|
||||
|
||||
({
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
}) _makeRepos() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: _noImapConnect,
|
||||
);
|
||||
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',
|
||||
imapPort: 993,
|
||||
imapSsl: true,
|
||||
smtpHost: 'smtp.example.com',
|
||||
smtpPort: 587,
|
||||
smtpSsl: false,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user