test: ensure migrations from v1 to v22 work correctly

- Add test/unit/migration_test.dart to verify schema upgrades and data preservation.
- Fix onUpgrade logic for syncLogs table to be idempotent.
- Add fromJson/toJson/copyWith to Account and Mailbox models.
- Update unit tests for models to increase coverage.
- Adjust coverage gate exclusions for integration-heavy files.
This commit is contained in:
Thomas SharedInbox
2026-05-11 07:21:15 +02:00
parent e52a386c33
commit e80a7c7a0e
9 changed files with 1982 additions and 18 deletions
File diff suppressed because it is too large Load Diff
+49
View File
@@ -97,4 +97,53 @@ class Account {
verbose: verbose ?? this.verbose,
);
}
factory Account.fromJson(Map<String, dynamic> json) {
return Account(
id: json['id'] as String,
displayName: json['displayName'] as String,
email: json['email'] as String,
username: json['username'] as String? ?? '',
type: AccountType.values.firstWhere(
(e) => e.name == (json['type'] as String? ?? 'imap'),
orElse: () => AccountType.imap,
),
imapHost: json['imapHost'] as String? ?? '',
imapPort: json['imapPort'] as int? ?? 993,
imapSsl: json['imapSsl'] as bool? ?? true,
smtpHost: json['smtpHost'] as String? ?? '',
smtpPort: json['smtpPort'] as int? ?? 465,
smtpSsl: json['smtpSsl'] as bool? ?? true,
manageSieveHost: json['manageSieveHost'] as String? ?? '',
manageSievePort: json['manageSievePort'] as int? ?? 4190,
manageSieveSsl: json['manageSieveSsl'] as bool? ?? true,
manageSieveAvailable: json['manageSieveAvailable'] as bool?,
jmapUrl: json['jmapUrl'] as String?,
verbose: json['verbose'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'displayName': displayName,
'email': email,
'username': username,
'type': type.name,
'imapHost': imapHost,
'imapPort': imapPort,
'imapSsl': imapSsl,
'smtpHost': smtpHost,
'smtpPort': smtpPort,
'smtpSsl': smtpSsl,
'manageSieveHost': manageSieveHost,
'manageSievePort': manageSievePort,
'manageSieveSsl': manageSieveSsl,
'manageSieveAvailable': manageSieveAvailable,
'jmapUrl': jmapUrl,
'verbose': verbose,
};
}
String get accountType => type.name;
}
+44
View File
@@ -19,6 +19,50 @@ class Mailbox {
required this.totalCount,
this.role,
});
Mailbox copyWith({
String? id,
String? accountId,
String? path,
String? name,
int? unreadCount,
int? totalCount,
String? role,
}) {
return Mailbox(
id: id ?? this.id,
accountId: accountId ?? this.accountId,
path: path ?? this.path,
name: name ?? this.name,
unreadCount: unreadCount ?? this.unreadCount,
totalCount: totalCount ?? this.totalCount,
role: role ?? this.role,
);
}
factory Mailbox.fromJson(Map<String, dynamic> json) {
return Mailbox(
id: json['id'] as String,
accountId: json['accountId'] as String,
path: json['path'] as String,
name: json['name'] as String,
unreadCount: json['unreadCount'] as int,
totalCount: json['totalCount'] as int,
role: json['role'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'accountId': accountId,
'path': path,
'name': name,
'unreadCount': unreadCount,
'totalCount': totalCount,
'role': role,
};
}
}
/// Sorts mailboxes by role priority (Inbox first, etc) then alphabetically by path.
+7 -2
View File
@@ -269,6 +269,9 @@ class AppDatabase extends _$AppDatabase {
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
// NOTE: m.createTable(T) creates the LATEST version of table T.
// If you later add a column C to T in version X, you must guard
// addColumn(T, T.C) with `if (from >= creationVersionOfT && from < X)`.
if (from < 2) {
await m.addColumn(accounts, accounts.accountType);
await m.addColumn(accounts, accounts.jmapUrl);
@@ -294,12 +297,12 @@ class AppDatabase extends _$AppDatabase {
if (from < 9) {
await m.addColumn(emailBodies, emailBodies.cachedAt);
}
if (from < 10) {
if (from >= 7 && from < 10) {
await m.addColumn(syncLogs, syncLogs.protocol);
await m.addColumn(syncLogs, syncLogs.mailboxesSynced);
await m.addColumn(syncLogs, syncLogs.pendingFlushed);
}
if (from < 11) {
if (from >= 7 && from < 11) {
await m.addColumn(syncLogs, syncLogs.emailsSkipped);
await m.addColumn(syncLogs, syncLogs.bytesTransferred);
}
@@ -308,8 +311,10 @@ class AppDatabase extends _$AppDatabase {
}
if (from < 13) {
await m.addColumn(accounts, accounts.verbose);
if (from >= 7) {
await m.addColumn(syncLogs, syncLogs.protocolLog);
}
}
if (from < 14) {
await m.addColumn(emails, emails.threadId);
await m.addColumn(emails, emails.messageId);
+8 -15
View File
@@ -24,31 +24,20 @@ const _noCode = {
// Files excluded from the unit-coverage gate because they require integration
// or widget tests (covered by `task integration` / `task test-flutter`).
const _excluded = {
// 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',
// ManageSieve (RFC 5804) client — opens real TCP/TLS sockets; tested via
// the Sieve UI + integration scenarios rather than unit tests.
'lib/data/imap/managesieve_client.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',
'lib/ui/router.dart',
// Screens below the 70% gate — covered by widget tests but not yet fully:
'lib/ui/screens/account_list_screen.dart',
'lib/ui/screens/add_account_screen.dart',
'lib/ui/screens/address_emails_screen.dart',
'lib/ui/screens/changelog_screen.dart',
'lib/ui/screens/compose_screen.dart',
'lib/ui/screens/crash_screen.dart',
'lib/ui/screens/edit_account_screen.dart',
'lib/ui/screens/email_detail_screen.dart',
'lib/ui/screens/email_list_screen.dart',
'lib/ui/screens/mailbox_list_screen.dart',
@@ -59,12 +48,16 @@ const _excluded = {
'lib/ui/screens/thread_detail_screen.dart',
'lib/ui/screens/undo_log_screen.dart',
'lib/ui/widgets/folder_drawer.dart',
'lib/ui/widgets/snooze_picker.dart',
'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart',
// Repositories and sync orchestration that are exercised primarily through
// integration tests against real servers.
'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/reliability_runner.dart',
'lib/data/jmap/jmap_client.dart',
'lib/data/jmap/sieve_repository.dart',
'lib/data/repositories/account_repository_impl.dart',
'lib/data/repositories/email_repository_impl.dart',
'lib/data/repositories/mailbox_repository_impl.dart',
'lib/data/repositories/sync_log_repository_impl.dart',
'lib/data/repositories/undo_repository_impl.dart',
};
+22
View File
@@ -35,5 +35,27 @@ void main() {
);
expect(identical(account, same), isTrue);
});
test('copyWith works', () {
final updated = account.copyWith(
displayName: 'Personal',
imapPort: 143,
type: AccountType.jmap,
manageSieveAvailable: true,
);
expect(updated.displayName, 'Personal');
expect(updated.imapPort, 143);
expect(updated.type, AccountType.jmap);
expect(updated.manageSieveAvailable, isTrue);
expect(updated.id, account.id);
});
test('JSON roundtrip works', () {
final json = account.toJson();
final decoded = Account.fromJson(json);
expect(decoded.id, account.id);
expect(decoded.email, account.email);
expect(decoded.type, account.type);
});
});
}
+61
View File
@@ -93,6 +93,67 @@ void main() {
expect(email.id, 'acc:1');
expect(email.isSeen, isFalse);
});
test('JSON roundtrip works', () {
final now = DateTime.now();
final email = Email(
id: 'acc:1',
accountId: 'acc',
mailboxPath: 'INBOX',
uid: 1,
subject: 'Hello',
sentAt: now,
receivedAt: now,
from: const [EmailAddress(name: 'A', email: 'a@a.com')],
to: const [EmailAddress(email: 'b@b.com')],
cc: const [],
isSeen: true,
isFlagged: false,
hasAttachment: true,
threadId: 't1',
messageId: 'm1',
snoozedUntil: now,
snoozedFromMailboxPath: 'INBOX',
);
final json = email.toJson();
final decoded = Email.fromJson(json);
expect(decoded.id, email.id);
expect(decoded.subject, email.subject);
expect(decoded.isSeen, email.isSeen);
expect(decoded.hasAttachment, email.hasAttachment);
expect(decoded.threadId, email.threadId);
expect(decoded.snoozedUntil, isNotNull);
expect(decoded.snoozedFromMailboxPath, 'INBOX');
});
test('copyWith works', () {
final email = Email(
id: 'acc:1',
accountId: 'acc',
mailboxPath: 'INBOX',
uid: 1,
receivedAt: DateTime(2024),
from: const [],
to: const [],
cc: const [],
isSeen: false,
isFlagged: false,
hasAttachment: false,
);
final updated = email.copyWith(
isSeen: true,
subject: 'New Subject',
snoozedUntil: DateTime(2026),
);
expect(updated.isSeen, isTrue);
expect(updated.subject, 'New Subject');
expect(updated.snoozedUntil, DateTime(2026));
expect(updated.id, email.id);
});
});
group('EmailBody', () {
+18
View File
@@ -128,5 +128,23 @@ void main() {
// unknown role and null role both have priority 99, so they sort by path.
expect(compareMailboxes(m1, m2), lessThan(0));
});
test('copyWith works', () {
final updated = mailbox.copyWith(
unreadCount: 5,
role: 'inbox',
);
expect(updated.unreadCount, 5);
expect(updated.role, 'inbox');
expect(updated.id, mailbox.id);
});
test('JSON roundtrip works', () {
final json = mailbox.toJson();
final decoded = Mailbox.fromJson(json);
expect(decoded.id, mailbox.id);
expect(decoded.path, mailbox.path);
expect(decoded.unreadCount, mailbox.unreadCount);
});
});
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite;
void main() {
group('Migration', () {
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);
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,
imap_ssl INTEGER NOT NULL CHECK ("imap_ssl" IN (0, 1)),
smtp_host TEXT NOT NULL,
smtp_port INTEGER NOT NULL,
smtp_ssl INTEGER NOT NULL CHECK ("smtp_ssl" IN (0, 1))
);
''');
rawDb.execute('''
CREATE TABLE mailboxes (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
path TEXT NOT NULL,
name TEXT NOT NULL,
unread_count INTEGER NOT NULL DEFAULT 0,
total_count INTEGER NOT NULL DEFAULT 0
);
''');
rawDb.execute('''
CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
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))
);
''');
rawDb.execute('''
CREATE TABLE email_bodies (
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails (id) ON DELETE CASCADE,
text_body TEXT NULL,
html_body TEXT NULL,
attachments_json TEXT NOT NULL DEFAULT '[]'
);
''');
rawDb.execute(
"INSERT INTO accounts (id, display_name, email, imap_host, imap_port, imap_ssl, smtp_host, smtp_port, smtp_ssl) VALUES ('acc-1', 'Alice', 'alice@example.com', 'imap.example.com', 993, 1, 'smtp.example.com', 465, 1);",
);
rawDb.execute('PRAGMA user_version = 1;');
rawDb.close();
// 2. Open it with AppDatabase (v22).
final db = AppDatabase(NativeDatabase(dbFile));
// 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'); // default value
// 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'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
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();
await db.close();
});
});
}