From 9ce598d21cda907b391ef790613f8338262cdd3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Fri, 17 Apr 2026 10:05:31 +0200 Subject: [PATCH] task check, working again. --- .github/workflows/ci.yml | 5 +- LATER.md | 56 +++ README.md | 50 +- Taskfile.yml | 52 ++- flake.lock | 6 +- flake.nix | 18 + lib/core/storage/secure_storage.dart | 6 + lib/data/db/database.dart | 2 +- .../repositories/account_repository_impl.dart | 7 +- .../repositories/email_repository_impl.dart | 30 +- .../repositories/mailbox_repository_impl.dart | 10 +- .../storage/flutter_secure_storage_impl.dart | 19 + lib/di.dart | 6 +- linux/CMakeLists.txt | 103 ++++- linux/flutter/CMakeLists.txt | 88 ++++ linux/my_application.cc | 9 +- pubspec.yaml | 1 + scripts/check_coverage.dart | 15 +- scripts/run_analyze.sh | 2 +- scripts/run_unit_tests.sh | 11 +- scripts/run_widget_tests.sh | 13 + test/unit/account_repository_impl_test.dart | 136 ++++++ test/unit/db_test_helper.dart | 21 + test/unit/email_repository_impl_test.dart | 437 ++++++++++++++++++ test/unit/fake_imap.dart | 215 +++++++++ test/unit/mailbox_repository_impl_test.dart | 221 +++++++++ 26 files changed, 1483 insertions(+), 56 deletions(-) create mode 100644 lib/core/storage/secure_storage.dart create mode 100644 lib/data/storage/flutter_secure_storage_impl.dart create mode 100644 linux/flutter/CMakeLists.txt create mode 100755 scripts/run_widget_tests.sh create mode 100644 test/unit/account_repository_impl_test.dart create mode 100644 test/unit/db_test_helper.dart create mode 100644 test/unit/email_repository_impl_test.dart create mode 100644 test/unit/fake_imap.dart create mode 100644 test/unit/mailbox_repository_impl_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdd68e7..bcaf171 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/LATER.md b/LATER.md index 5651f26..ccba6de 100644 --- a/LATER.md +++ b/LATER.md @@ -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) diff --git a/README.md b/README.md index e7d7545..38342cb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Taskfile.yml b/Taskfile.yml index 135b655..15131a9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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] diff --git a/flake.lock b/flake.lock index 3e5bb9f..f6d9478 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { diff --git a/flake.nix b/flake.nix index 83d19e1..a361e86 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/lib/core/storage/secure_storage.dart b/lib/core/storage/secure_storage.dart new file mode 100644 index 0000000..70b990b --- /dev/null +++ b/lib/core/storage/secure_storage.dart @@ -0,0 +1,6 @@ +/// Minimal interface over platform secure credential storage. +abstract class SecureStorage { + Future write({required String key, required String? value}); + Future read({required String key}); + Future delete({required String key}); +} diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 3ce2d94..5836e75 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -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; diff --git a/lib/data/repositories/account_repository_impl.dart b/lib/data/repositories/account_repository_impl.dart index 38b8ed8..9c20382 100644 --- a/lib/data/repositories/account_repository_impl.dart +++ b/lib/data/repositories/account_repository_impl.dart @@ -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> observeAccounts() { diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index a47a474..2eedb6f 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -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 Function( + account_model.Account account, String password); +typedef SmtpConnectFn = Future 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 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 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('"', '\\"'); diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart index b5c5490..8a437fc 100644 --- a/lib/data/repositories/mailbox_repository_impl.dart +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -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> observeMailboxes(String accountId) { @@ -27,7 +33,7 @@ class MailboxRepositoryImpl implements MailboxRepository { Future 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) { diff --git a/lib/data/storage/flutter_secure_storage_impl.dart b/lib/data/storage/flutter_secure_storage_impl.dart new file mode 100644 index 0000000..d458ee7 --- /dev/null +++ b/lib/data/storage/flutter_secure_storage_impl.dart @@ -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 write({required String key, required String? value}) => + _impl.write(key: key, value: value); + + @override + Future read({required String key}) => _impl.read(key: key); + + @override + Future delete({required String key}) => _impl.delete(key: key); +} diff --git a/lib/di.dart b/lib/di.dart index 856fb63..6324638 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -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((ref) { final db = AppDatabase(); @@ -16,7 +17,10 @@ final dbProvider = Provider((ref) { }); final accountRepositoryProvider = Provider((ref) { - return AccountRepositoryImpl(ref.watch(dbProvider)); + return AccountRepositoryImpl( + ref.watch(dbProvider), + const FlutterSecureStorageImpl(), + ); }); final mailboxRepositoryProvider = Provider((ref) { diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 7e4f4a1..6d57dbc 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -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 "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>: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() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -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} +) diff --git a/linux/my_application.cc b/linux/my_application.cc index 3a8f91c..e4db97b 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -34,9 +34,9 @@ static void my_application_activate(GApplication* application) { gtk_widget_grab_focus(GTK_WIDGET(view)); } -static void my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { MyApplication* self = MY_APPLICATION(application); // Strip the first argument as it is the binary name. self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); @@ -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) { diff --git a/pubspec.yaml b/pubspec.yaml index 71f7289..4548cc7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 258b079..a9f0e43 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -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', diff --git a/scripts/run_analyze.sh b/scripts/run_analyze.sh index 7fee2f4..bec4d32 100755 --- a/scripts/run_analyze.sh +++ b/scripts/run_analyze.sh @@ -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" diff --git a/scripts/run_unit_tests.sh b/scripts/run_unit_tests.sh index e537809..6250c1b 100755 --- a/scripts/run_unit_tests.sh +++ b/scripts/run_unit_tests.sh @@ -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" diff --git a/scripts/run_widget_tests.sh b/scripts/run_widget_tests.sh new file mode 100755 index 0000000..477093d --- /dev/null +++ b/scripts/run_widget_tests.sh @@ -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" diff --git a/test/unit/account_repository_impl_test.dart b/test/unit/account_repository_impl_test.dart new file mode 100644 index 0000000..8f5b5b1 --- /dev/null +++ b/test/unit/account_repository_impl_test.dart @@ -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 = {}; + + @override + Future write({required String key, required String? value}) async { + if (value == null) { + _map.remove(key); + } else { + _map[key] = value; + } + } + + @override + Future read({required String key}) async => _map[key]; + + @override + Future 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()), + ); + }); + + 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()), + ); + }); + + 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); + }); + }); +} diff --git a/test/unit/db_test_helper.dart b/test/unit/db_test_helper.dart new file mode 100644 index 0000000..642c256 --- /dev/null +++ b/test/unit/db_test_helper.dart @@ -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()); diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart new file mode 100644 index 0000000..cd2ad91 --- /dev/null +++ b/test/unit/email_repository_impl_test.dart @@ -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 _noImapConnect(Account a, String p) => + Future.error(UnsupportedError('IMAP unavailable in unit tests')); + +Future _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()), + ); + }); + + 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()), + ); + }); + + 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('

Hello

'), + ), + ); + + final body = await r.emails.getEmailBody('acc-1:1'); + expect(body.textBody, 'Hello'); + expect(body.htmlBody, '

Hello

'); + }); + + 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); + }); + }); +} diff --git a/test/unit/fake_imap.dart b/test/unit/fake_imap.dart new file mode 100644 index 0000000..de050d6 --- /dev/null +++ b/test/unit/fake_imap.dart @@ -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 fetchResults = []; + List listMailboxesResult = []; + List 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 selectMailboxByPath( + String path, { + bool enableCondStore = false, + imap.QResyncParameters? qresync, + }) async => + imap.Mailbox( + encodedName: path, + encodedPath: path, + flags: [], + pathSeparator: '/', + ); + + @override + Future fetchMessages( + imap.MessageSequence sequence, + String? fetchContentDefinition, { + int? changedSinceModSequence, + Duration? responseTimeout, + }) async => + imap.FetchImapResult(List.of(fetchResults), null); + + @override + Future uidFetchMessage( + int messageUid, + String fetchContentDefinition, { + Duration? responseTimeout, + }) async => + imap.FetchImapResult( + fetchResults.isEmpty ? [] : [fetchResults.first], + null, + ); + + @override + Future uidMarkSeen( + imap.MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) async { + markSeenCalls++; + return imap.StoreImapResult(); + } + + @override + Future uidMarkUnseen( + imap.MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) async { + markUnseenCalls++; + return imap.StoreImapResult(); + } + + @override + Future uidMarkFlagged( + imap.MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) async { + markFlaggedCalls++; + return imap.StoreImapResult(); + } + + @override + Future uidMarkUnflagged( + imap.MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) async { + markUnflaggedCalls++; + return imap.StoreImapResult(); + } + + @override + Future uidMarkDeleted( + imap.MessageSequence sequence, { + bool? silent, + int? unchangedSinceModSequence, + }) async { + markDeletedCalls++; + return imap.StoreImapResult(); + } + + @override + Future uidExpunge(imap.MessageSequence sequence) async { + expungeCalls++; + return null; + } + + @override + Future uidMove( + imap.MessageSequence sequence, { + imap.Mailbox? targetMailbox, + String? targetMailboxPath, + }) async { + moveEmailCalls++; + return imap.GenericImapResult(); + } + + @override + Future uidSearchMessages({ + String searchCriteria = 'UNSEEN', + List? returnOptions, + Duration? responseTimeout, + }) async { + final result = imap.SearchImapResult(); + if (searchUids.isNotEmpty) { + result.matchingSequence = + imap.MessageSequence.fromIds(searchUids, isUid: true); + } + return result; + } + + @override + Future> listMailboxes({ + String path = '""', + bool recursive = false, + List? mailboxPatterns, + List? selectionOptions, + List? returnOptions, + }) async => + List.of(listMailboxesResult); + + @override + Future statusMailbox( + imap.Mailbox box, + List 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 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 sendMessage( + imap.MimeMessage message, { + bool use8BitEncoding = false, + imap.MailAddress? from, + List? recipients, + }) async { + messageSent = true; + return imap.SmtpResponse(['250 OK']); + } + + @override + Future 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 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), + ); +} diff --git a/test/unit/mailbox_repository_impl_test.dart b/test/unit/mailbox_repository_impl_test.dart new file mode 100644 index 0000000..0f73e90 --- /dev/null +++ b/test/unit/mailbox_repository_impl_test.dart @@ -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 _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()), + ); + }); + + 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); + }); + }); +}