task check, working again.

This commit is contained in:
Thomas Güttler
2026-04-17 10:05:31 +02:00
parent 24482c7e4b
commit 9ce598d21c
26 changed files with 1483 additions and 56 deletions
+3 -2
View File
@@ -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:
+56
View File
@@ -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)
+48 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
+18
View File
@@ -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
+6
View File
@@ -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});
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+88
View File
@@ -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}
)
+5 -4
View File
@@ -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) {
+1
View File
@@ -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
+11 -4
View File
@@ -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 -1
View File
@@ -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"
+10 -1
View File
@@ -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"
+13
View File
@@ -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"
+136
View File
@@ -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);
});
});
}
+21
View File
@@ -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());
+437
View File
@@ -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);
});
});
}
+215
View File
@@ -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),
);
}
+221
View File
@@ -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);
});
});
}