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:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,7 +311,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
if (from < 13) {
|
||||
await m.addColumn(accounts, accounts.verbose);
|
||||
await m.addColumn(syncLogs, syncLogs.protocolLog);
|
||||
if (from >= 7) {
|
||||
await m.addColumn(syncLogs, syncLogs.protocolLog);
|
||||
}
|
||||
}
|
||||
if (from < 14) {
|
||||
await m.addColumn(emails, emails.threadId);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user