Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 43fb2a594e test(T2): add widget tests for ThreadDetailScreen and SearchScreen
Six tests for ThreadDetailScreen:
- empty thread shows placeholder message
- sender name rendered in card
- last email is expanded by default (reply/delete buttons visible)
- tapping expanded card collapses it
- flagged email shows star icon
- expanded card renders plain text body

Six tests for SearchScreen:
- placeholder shown when field is empty
- typing fewer than 3 characters keeps placeholder (no search fires)
- search returning empty shows "No results"
- email results shown under "Messages" section
- folder results shown under "Folders" section
- clear button resets results to placeholder

Also adds ThreadDetailScreen route to the shared test router in
helpers.dart so tests can navigate to it by URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 04:54:32 +02:00
4 changed files with 417 additions and 181 deletions
+24 -181
View File
@@ -4,22 +4,11 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite;
/// Reads all column names for [tableName] from [db].
Future<List<String>> _tableColumns(AppDatabase db, String tableName) async {
final rows = await db.customSelect('PRAGMA table_info($tableName)').get();
return rows.map((r) => r.read<String>('name')).toList();
}
void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 24);
await db.close();
});
test('upgrade from v1 to latest checks all added columns', () async {
final dbFile = File('test_migration_v1.db');
test('upgrade from v1 to latest', () async {
// 1. Create a V1 database using raw sqlite3.
final dbFile = File('test_migration.db');
if (dbFile.existsSync()) dbFile.deleteSync();
final rawDb = sqlite.sqlite3.open(dbFile.path);
@@ -78,187 +67,41 @@ void main() {
rawDb.execute('PRAGMA user_version = 1;');
rawDb.close();
// 2. Open it with AppDatabase (v22).
final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration by performing a query.
// Trigger migration by performing a simple query.
final accs = await db.select(db.accounts).get();
expect(accs, hasLength(1));
expect(accs.first.displayName, 'Alice');
expect(accs.first.accountType, 'imap');
expect(accs.first.accountType, 'imap'); // default value
// v2v3: accounts columns.
final accountColumns = await _tableColumns(db, 'accounts');
expect(
accountColumns,
containsAll(['account_type', 'jmap_url', 'username']),
);
// 3. Verify that all columns exist.
// If migration failed, it would have thrown an exception during opening or query.
final tableInfo =
await db.customSelect('PRAGMA table_info(emails)').get();
final columns = tableInfo.map((r) => r.read<String>('name')).toList();
expect(columns, contains('thread_id'));
expect(columns, contains('snoozed_until'));
expect(columns, contains('snoozed_from_mailbox_path'));
final accountsInfo =
await db.customSelect('PRAGMA table_info(accounts)').get();
final accountColumns =
accountsInfo.map((r) => r.read<String>('name')).toList();
expect(accountColumns, contains('account_type'));
expect(accountColumns, contains('username'));
expect(accountColumns, contains('manage_sieve_host'));
// v14: threading columns.
final emailColumns = await _tableColumns(db, 'emails');
expect(
emailColumns,
containsAll(['thread_id', 'message_id', 'in_reply_to', 'references']),
);
// v22: snooze columns.
expect(
emailColumns,
containsAll(['snoozed_until', 'snoozed_from_mailbox_path']),
);
// v23: list-unsubscribe header column.
expect(emailColumns, contains('list_unsubscribe_header'));
// v8: mailboxes role column.
final mailboxColumns = await _tableColumns(db, 'mailboxes');
expect(mailboxColumns, contains('role'));
// v9: email_bodies cached_at column.
final bodyColumns = await _tableColumns(db, 'email_bodies');
expect(bodyColumns, contains('cached_at'));
expect(bodyColumns, contains('headers_json'));
// v4: drafts table with v24 imap_server_id column.
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
// v5, v6, v7, v12, v17, v19, v21: new tables.
final allTables = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='table'")
.get();
final tableNames = allTables.map((r) => r.read<String>('name')).toList();
expect(
tableNames,
containsAll([
'sync_states', // v5
'pending_changes', // v6
'sync_logs', // v7
'sync_log_mailboxes', // v12
'threads', // v17
'sync_health', // v19
'undo_actions', // v21
]),
);
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test(
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
() async {
final dbFile = File('test_migration_v22.db');
if (dbFile.existsSync()) dbFile.deleteSync();
// Build a v22 database schema directly with raw SQL.
final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute('''
CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT NOT NULL,
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL DEFAULT 993,
imap_ssl INTEGER NOT NULL DEFAULT 1 CHECK ("imap_ssl" IN (0, 1)),
smtp_host TEXT NOT NULL DEFAULT '',
smtp_port INTEGER NOT NULL DEFAULT 465,
smtp_ssl INTEGER NOT NULL DEFAULT 1 CHECK ("smtp_ssl" IN (0, 1)),
account_type TEXT NOT NULL DEFAULT 'imap',
jmap_url TEXT NULL,
username TEXT NULL,
manage_sieve_host TEXT NULL,
manage_sieve_port INTEGER NULL,
manage_sieve_ssl INTEGER NULL,
manage_sieve_available INTEGER NOT NULL DEFAULT 0 CHECK ("manage_sieve_available" IN (0, 1)),
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
);
''');
rawDb.execute('''
CREATE TABLE drafts (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NULL,
reply_to_email_id TEXT NULL,
to_text TEXT NOT NULL DEFAULT '',
cc_text TEXT NOT NULL DEFAULT '',
subject_text TEXT NOT NULL DEFAULT '',
body_text TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
''');
rawDb.execute('''
CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL,
mailbox_path TEXT NOT NULL,
uid INTEGER NOT NULL,
subject TEXT NULL,
sent_at INTEGER NULL,
received_at INTEGER NOT NULL,
from_json TEXT NOT NULL DEFAULT '[]',
to_addresses TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
preview TEXT NULL,
is_seen INTEGER NOT NULL DEFAULT 0 CHECK ("is_seen" IN (0, 1)),
is_flagged INTEGER NOT NULL DEFAULT 0 CHECK ("is_flagged" IN (0, 1)),
has_attachment INTEGER NOT NULL DEFAULT 0 CHECK ("has_attachment" IN (0, 1)),
thread_id TEXT NULL,
message_id TEXT NULL,
in_reply_to TEXT NULL,
"references" TEXT NULL,
snoozed_until INTEGER NULL,
snoozed_from_mailbox_path TEXT NULL
);
''');
rawDb.execute('PRAGMA user_version = 22;');
rawDb.close();
final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration.
await db.select(db.accounts).get();
final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 24', () async {
test('fresh install (v22) works', () async {
final db = AppDatabase(NativeDatabase.memory());
// Just ensure we can create everything and query.
await db.select(db.accounts).get();
final allTables = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='table'")
.get();
final tableNames = allTables.map((r) => r.read<String>('name')).toSet();
expect(
tableNames,
containsAll([
'accounts',
'mailboxes',
'emails',
'email_bodies',
'drafts',
'sync_states',
'pending_changes',
'sync_logs',
'sync_log_mailboxes',
'threads',
'sync_health',
'undo_actions',
]),
);
final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
await db.close();
});
});
+13
View File
@@ -30,6 +30,7 @@ import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
import 'package:sharedinbox/ui/screens/search_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
// ---------------------------------------------------------------------------
// Fake repositories
@@ -381,6 +382,18 @@ Widget buildApp({
),
],
),
GoRoute(
path: ':mailboxPath/threads/:threadId',
builder: (ctx, state) => ThreadDetailScreen(
accountId: state.pathParameters['accountId']!,
mailboxPath: Uri.decodeComponent(
state.pathParameters['mailboxPath']!,
),
threadId: Uri.decodeComponent(
state.pathParameters['threadId']!,
),
),
),
],
),
],
+182
View File
@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
void main() {
group('SearchScreen', () {
testWidgets('shows placeholder hint text when empty', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Type 3+ characters to search'), findsOneWidget);
});
testWidgets('typing fewer than 3 characters does not trigger search', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'hi');
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('Type 3+ characters to search'), findsOneWidget);
expect(find.text('No results'), findsNothing);
});
testWidgets('shows "No results" when search returns nothing', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'xyz');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('No results'), findsOneWidget);
});
testWidgets('shows email results under "Messages" section', (
tester,
) async {
final email = testEmail(subject: 'Invoice Q3');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'inv');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Messages'), findsOneWidget);
expect(find.text('Invoice Q3'), findsOneWidget);
});
testWidgets('shows folder results under "Folders" section', (
tester,
) async {
const archiveMailbox = Mailbox(
id: 'acc-1:Archive',
accountId: 'acc-1',
path: 'Archive',
name: 'Archive',
unreadCount: 0,
totalCount: 5,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([archiveMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'arc');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Folders'), findsOneWidget);
expect(find.text('Archive'), findsOneWidget);
});
testWidgets('tapping clear button resets results to placeholder', (
tester,
) async {
final email = testEmail(subject: 'Found email');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'found');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Found email'), findsOneWidget);
await tester.tap(find.byIcon(Icons.clear));
await tester.pumpAndSettle();
expect(find.text('Found email'), findsNothing);
expect(find.text('Type 3+ characters to search'), findsOneWidget);
});
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
Email _threadEmail({
String id = 'acc-1:10',
bool isFlagged = false,
bool isSeen = true,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 10,
threadId: 'thread-1',
subject: 'Project update',
receivedAt: DateTime(2024, 6),
sentAt: DateTime(2024, 6, 1, 9),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: false,
);
void main() {
group('ThreadDetailScreen', () {
testWidgets('shows "Thread not found or empty" when thread is empty', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Thread not found or empty'), findsOneWidget);
});
testWidgets('shows sender name for email in thread', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Bob'), findsOneWidget);
});
testWidgets('last email in thread is expanded by default', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
textBody: 'Hello body text',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
// Reply and delete buttons are visible for the expanded card.
expect(find.byIcon(Icons.reply), findsOneWidget);
expect(find.byIcon(Icons.delete_outline), findsOneWidget);
});
testWidgets('tapping an expanded card collapses it', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
textBody: 'Hello body text',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
// Tap the expand_less icon to collapse.
await tester.tap(find.byIcon(Icons.expand_less));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.reply), findsNothing);
expect(find.byIcon(Icons.expand_more), findsOneWidget);
});
testWidgets('flagged email shows star icon', (tester) async {
final email = _threadEmail(isFlagged: true);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.star), findsOneWidget);
});
testWidgets('expanded card shows plain text body', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
textBody: 'Body content here',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Body content here'), findsOneWidget);
});
});
}