diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 3186575..2ea8f0b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -4,12 +4,32 @@ on: branches: - main pull_request: +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: check: name: Full Project Check runs-on: ubuntu-latest timeout-minutes: 60 steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 - name: Setup Dagger Remote Engine env: diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 1d6bc87..7ad874a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -15,6 +15,23 @@ jobs: linux: ${{ steps.diff.outputs.linux }} steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -141,6 +158,23 @@ jobs: if: needs.check-changes.outputs.android == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 100 @@ -175,6 +209,23 @@ jobs: if: needs.check-changes.outputs.android == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 100 @@ -203,6 +254,23 @@ jobs: if: needs.check-changes.outputs.linux == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 100 @@ -236,6 +304,23 @@ jobs: timeout-minutes: 5 steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue env: FORGEJO_TOKEN: ${{ github.token }} diff --git a/.forgejo/workflows/firebase-tests.yml b/.forgejo/workflows/firebase-tests.yml index edd3e81..7022957 100644 --- a/.forgejo/workflows/firebase-tests.yml +++ b/.forgejo/workflows/firebase-tests.yml @@ -14,6 +14,23 @@ jobs: has_changes: ${{ steps.diff.outputs.has_changes }} steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -50,6 +67,23 @@ jobs: if: needs.check-changes.outputs.has_changes == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 1 diff --git a/.forgejo/workflows/website.yml b/.forgejo/workflows/website.yml index 43c188d..ee5c575 100644 --- a/.forgejo/workflows/website.yml +++ b/.forgejo/workflows/website.yml @@ -18,6 +18,23 @@ jobs: timeout-minutes: 60 steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: submodules: recursive diff --git a/ci/main.go b/ci/main.go index 7d54743..09820c1 100644 --- a/ci/main.go +++ b/ci/main.go @@ -539,7 +539,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) { return m.WithStalwart(m.setup(m.backendSrc())). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + - `flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + + `flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). Stdout(ctx) } @@ -570,7 +570,7 @@ func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) { return m.WithStalwart(m.setup(m.backendSrc())). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + - `flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + + `flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). Stdout(ctx) } diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 28466bf..9a6e4b4 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -58,7 +58,7 @@ abstract class EmailRepository { ); /// Searches the local DB across all mailboxes of [accountId] (or all accounts - /// if null) by subject and preview. Fast, works offline. + /// if null) by subject, preview, and notes. Fast, works offline. Future> searchEmailsGlobal(String? accountId, String query); /// Returns all locally cached emails in any mailbox of [accountId] (or all diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 103df36..93d3939 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sharedinbox/core/db_schema_version.dart'; +import 'package:sqlite3/sqlite3.dart' show Database; part 'database.g.dart'; @@ -793,18 +794,34 @@ Future resolveDatabasePathForTesting() => _resolveDatabasePath(); void resetDatabasePathForTesting() => _dbPath = null; Future androidFallbackPathForTesting() => _androidFallbackPath(); +/// Configures PRAGMAs on a newly opened SQLite connection. +/// +/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY +/// instead of immediately failing. +/// +/// journal_mode = WAL is wrapped in a try/catch because a concurrent +/// WorkManager background task may already have the DB open when the app +/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is +/// returned in that situation; it only occurs when the DB is already in WAL +/// mode, so the pragma would be a no-op anyway and it is safe to continue. +void _setupPragmas(Database db) { + db.execute('PRAGMA busy_timeout = 5000;'); + try { + db.execute('PRAGMA journal_mode = WAL;'); + } on SqliteException catch (e) { + // resultCode strips the extended bits: both SQLITE_BUSY (5) and + // SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else. + if (e.resultCode != 5) rethrow; + } +} + LazyDatabase _openConnection() { return LazyDatabase(() async { final file = File(await _resolveDatabasePath()); - return NativeDatabase.createInBackground( - file, - setup: (db) { - // WAL lets readers and writers proceed concurrently (different account - // sync loops share the same DB). busy_timeout makes SQLite retry for - // up to 5 s instead of immediately returning SQLITE_BUSY. - db.execute('PRAGMA journal_mode = WAL;'); - db.execute('PRAGMA busy_timeout = 5000;'); - }, - ); + return NativeDatabase.createInBackground(file, setup: _setupPragmas); }); } + +// Exposed so tests can run the exact production setup logic on a raw +// sqlite3 connection (same pattern as resolveDatabasePathForTesting). +void setupPragmasForTesting(Database db) => _setupPragmas(db); diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 2d5218d..2cfbc93 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2922,9 +2922,9 @@ class EmailRepositoryImpl implements EmailRepository { final sql = accountId != null ? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' - ' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50' + ' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50' : 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' - ' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50'; + ' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50'; final variables = accountId != null ? [Variable(ftsQuery), Variable(accountId)] : [Variable(ftsQuery)]; @@ -2934,6 +2934,56 @@ class EmailRepositoryImpl implements EmailRepository { final emailRows = await Future.wait( queryRows.map((r) => _db.emails.mapFromRow(r)), ); + + final noteRows = await _searchEmailsByNotes(accountId, null, query); + + final seen = {}; + final merged = []; + for (final e in [...emailRows.map(_toModel), ...noteRows]) { + if (seen.add(e.id)) merged.add(e); + } + merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); + return merged; + } + + /// Returns emails whose associated notes contain all words from [query]. + /// Optionally filtered by [accountId] and [mailboxPath]. + Future> _searchEmailsByNotes( + String? accountId, + String? mailboxPath, + String query, + ) async { + final words = + query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + if (words.isEmpty) return []; + + final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND '); + final likeVars = words.map((w) => Variable('%$w%')).toList(); + + final extraConditions = StringBuffer(); + final extraVars = >[]; + if (accountId != null) { + extraConditions.write(' AND e.account_id = ?'); + extraVars.add(Variable(accountId)); + } + if (mailboxPath != null) { + extraConditions.write(' AND e.mailbox_path = ?'); + extraVars.add(Variable(mailboxPath)); + } + + final sql = 'SELECT DISTINCT e.* FROM emails e' + ' JOIN email_notes n ON n.message_id = e.message_id' + ' AND n.account_id = e.account_id' + ' WHERE $noteConditions$extraConditions' + ' ORDER BY e.received_at DESC LIMIT 50'; + + final rows = await _db.customSelect( + sql, + variables: [...likeVars, ...extraVars], + readsFrom: {_db.emails, _db.emailNotes}, + ).get(); + final emailRows = + await Future.wait(rows.map((r) => _db.emails.mapFromRow(r))); return emailRows.map(_toModel).toList(); } @@ -2943,9 +2993,7 @@ class EmailRepositoryImpl implements EmailRepository { static String _toFtsQuery(String query) { final words = query .trim() - .split(RegExp(r'\s+')) - .where((w) => w.isNotEmpty) - .map((w) => w.replaceAll(RegExp(r'[^\w]'), '')) + .split(RegExp(r'[^\w]+')) .where((w) => w.isNotEmpty) .toList(); if (words.isEmpty) return ''; @@ -3047,6 +3095,8 @@ class EmailRepositoryImpl implements EmailRepository { } @override + // Results are limited to emails already synced into the local SQLite FTS5 + // index; call syncEmails first to ensure the index is up-to-date. Future> searchEmails( String accountId, String mailboxPath, @@ -3057,7 +3107,7 @@ class EmailRepositoryImpl implements EmailRepository { const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' ' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?' - ' ORDER BY rank LIMIT 50'; + ' ORDER BY e.received_at DESC LIMIT 50'; final variables = [ Variable(ftsQuery), Variable(accountId), @@ -3069,7 +3119,16 @@ class EmailRepositoryImpl implements EmailRepository { final emailRows = await Future.wait( queryRows.map((r) => _db.emails.mapFromRow(r)), ); - return emailRows.map(_toModel).toList(); + + final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query); + + final seen = {}; + final merged = []; + for (final e in [...emailRows.map(_toModel), ...noteRows]) { + if (seen.add(e.id)) merged.add(e); + } + merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); + return merged; } // ── Helpers ──────────────────────────────────────────────────────────────── diff --git a/lib/main.dart b/lib/main.dart index 20c6a2a..5bae652 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -109,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState { theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, + splashFactory: NoSplash.splashFactory, ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( @@ -116,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState { brightness: Brightness.dark, ), useMaterial3: true, + splashFactory: NoSplash.splashFactory, ), routerConfig: router, ); diff --git a/lib/ui/screens/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 1567556..c23f192 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( appBar: AppBar( title: const Text('Something went wrong'), diff --git a/pubspec.yaml b/pubspec.yaml index a4b5218..99c9055 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: # Local persistence (offline-first) drift: ^2.20.3 + sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas) sqlite3_flutter_libs: ^0.6.0+eol path_provider: ^2.1.5 path: ^1.9.1 @@ -78,7 +79,6 @@ dev_dependencies: mockito: ^5.4.4 fake_async: ^1.3.1 path_provider_platform_interface: ^2.1.2 - sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close() url_launcher_platform_interface: ^2.3.2 plugin_platform_interface: ^2.1.8 flutter_launcher_icons: ^0.14.0 diff --git a/test/backend/chaos_monkey_test.dart b/test/backend/chaos_monkey_test.dart index f6715a4..485f387 100644 --- a/test/backend/chaos_monkey_test.dart +++ b/test/backend/chaos_monkey_test.dart @@ -10,6 +10,9 @@ // CHAOS_ROUNDS (default: 30) — number of random operations to perform // CHAOS_SEED (default: current epoch ms) — seed for reproducibility +@Tags(['nightly']) +library; + import 'dart:io'; import 'dart:math'; @@ -132,7 +135,7 @@ void main() { tearDown(() => db.close()); test('chaos monkey — random operations do not crash the repository', - () async { + timeout: Timeout.none, () async { final seedStr = _env('CHAOS_SEED'); final seed = seedStr.isEmpty ? DateTime.now().millisecondsSinceEpoch diff --git a/test/backend/email_repository_imap_test.dart b/test/backend/email_repository_imap_test.dart index 50e009e..6d20993 100644 --- a/test/backend/email_repository_imap_test.dart +++ b/test/backend/email_repository_imap_test.dart @@ -433,6 +433,7 @@ void main() { final r = makeRepo(); await r.accounts.addAccount(account, userPass); + await r.emails.syncEmails('test', 'INBOX'); final results = await r.emails.searchEmails( 'test', diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index 78ed2f9..710990e 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -486,6 +486,158 @@ void main() { expect(results.first.mailboxPath, 'INBOX'); }); + test('searchEmailsGlobal includes emails matched by note text', () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + + // Email whose subject does NOT match — but its note does. + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + messageId: const Value(''), + subject: const Value('Weekly report'), + receivedAt: DateTime(2024), + ), + ); + // Add a note referencing the email's messageId. + await r.db.into(r.db.emailNotes).insert( + EmailNotesCompanion.insert( + id: 'note-1', + accountId: 'acc-1', + messageId: '', + noteText: 'Urgent follow-up needed', + serverId: '42', + createdAt: DateTime(2024), + ), + ); + + final results = await r.emails.searchEmailsGlobal(null, 'urgent'); + expect(results, hasLength(1)); + expect(results.first.subject, 'Weekly report'); + }); + + test('searchEmails includes emails matched by note text in mailbox', + () 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, + messageId: const Value(''), + subject: const Value('Project update'), + receivedAt: DateTime(2024), + ), + ); + // Email in a different mailbox — its note must not appear in INBOX search. + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:2', + accountId: 'acc-1', + mailboxPath: 'Sent', + uid: 2, + messageId: const Value(''), + subject: const Value('Other email'), + receivedAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emailNotes).insert( + EmailNotesCompanion.insert( + id: 'note-1', + accountId: 'acc-1', + messageId: '', + noteText: 'remember to call client', + serverId: '42', + createdAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emailNotes).insert( + EmailNotesCompanion.insert( + id: 'note-2', + accountId: 'acc-1', + messageId: '', + noteText: 'remember to call client', + serverId: '43', + createdAt: DateTime(2024), + ), + ); + + final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client'); + expect(results, hasLength(1)); + expect(results.first.subject, 'Project update'); + expect(results.first.mailboxPath, 'INBOX'); + }); + + test('searchEmailsGlobal returns results sorted by receivedAt descending', + () 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, + subject: const Value('Older report'), + receivedAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:2', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 2, + subject: const Value('Newer report'), + receivedAt: DateTime(2024, 6), + ), + ); + + final results = await r.emails.searchEmailsGlobal(null, 'report'); + expect(results, hasLength(2)); + expect(results[0].subject, 'Newer report'); + expect(results[1].subject, 'Older report'); + }); + + test('searchEmails returns results sorted by receivedAt descending', + () 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, + subject: const Value('Older meeting'), + receivedAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:2', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 2, + subject: const Value('Newer meeting'), + receivedAt: DateTime(2024, 6), + ), + ); + + final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting'); + expect(results, hasLength(2)); + expect(results[0].subject, 'Newer meeting'); + expect(results[1].subject, 'Older meeting'); + }); + test( 'searchAddresses returns results sorted by most recently used', () async { diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index e6e375f..c52cfd6 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -510,4 +510,40 @@ void main() { await db.close(); }); }); + + // Regression test for https://codeberg.org/guettli/sharedinbox/issues/508: + // _openConnection's setup callback must not crash when PRAGMA journal_mode = + // WAL fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) + // because a WorkManager background task already has the DB open in WAL mode. + group('WAL setup (#508)', () { + test( + 'setupPragmasForTesting does not throw when WAL is already active and ' + 'another connection holds an open read transaction', + () { + final dbFile = File('test_wal_busy_508.db'); + if (dbFile.existsSync()) dbFile.deleteSync(); + addTearDown(() { + if (dbFile.existsSync()) dbFile.deleteSync(); + }); + + // conn1: enable WAL and keep a read transaction open — simulates a + // WorkManager background task that opened the DB before the foreground + // app starts. + final conn1 = sqlite.sqlite3.open(dbFile.path); + conn1.execute('PRAGMA journal_mode = WAL;'); + conn1.execute('BEGIN;'); + conn1.select('SELECT 1;'); + + // conn2: run the exact production setup through setupPragmasForTesting. + // This must not throw even though conn1 holds an open transaction and + // the DB is already in WAL mode. + final conn2 = sqlite.sqlite3.open(dbFile.path); + expect(() => setupPragmasForTesting(conn2), returnsNormally); + + conn1.execute('ROLLBACK;'); + conn1.close(); + conn2.close(); + }, + ); + }); } diff --git a/test/widget/about_screen_test.dart b/test/widget/about_screen_test.dart index 2c3cdd7..64c5988 100644 --- a/test/widget/about_screen_test.dart +++ b/test/widget/about_screen_test.dart @@ -50,7 +50,10 @@ Widget _buildScreen({List accounts = const []}) { FakeAccountRepository(accounts), ), ], - child: const MaterialApp(home: AboutScreen()), + child: MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), + home: const AboutScreen(), + ), ); }