From c5c2da8e68fa743d922f7c893f1397fbea223346 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 22:04:35 -0500 Subject: [PATCH] test(db): add Room migration tests with MigrationTestHelper Closes the gap where app/schemas was exported but never validated (#63): - androidx.room:room-testing (androidTest) + ship the exported schemas as androidTest assets so MigrationTestHelper can build old-version databases. - MigrationTest: 11->12 asserts folders.specialUse arrives defaulting to 0 with existing rows intact; a chain-integrity test requires exactly one migration per version step up to the newest exported schema; a full v7->latest replay validates every step against its exported JSON and asserts seeded v7 data and each migration's backfills survive. The migration list is discovered from Migrations.kt and the target version from the exported schemas, so a future migration is covered by just committing its schema JSON. - Pin kotlinx-serialization to 1.8.1 via its BOM: androidx.savedstate pins 1.7.3 transitively (shared with androidTest by AGP 9 consistent resolution), and Room 2.8's schema-bundle serializers need >= 1.8.0 or MigrationTestHelper throws AbstractMethodError parsing the schema JSON. Identical to the pin already proven on the feat-fetch-all-retention branch, so the two merge cleanly in either order. - Refresh comments that described migration tests as future work. Co-Authored-By: Claude Fable 5 --- app/build.gradle.kts | 11 +- .../data/local/LibreMailDatabaseTest.kt | 6 +- .../org/libremail/data/local/MigrationTest.kt | 260 ++++++++++++++++++ gradle/libs.versions.toml | 10 + 4 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 36e4778..d252932 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -106,6 +106,9 @@ android { includeInBundle = false } + // Ship the exported Room schemas as androidTest assets so MigrationTestHelper can load them. + sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas") + packaging { resources { // Angus Mail / Jakarta Activation (added later) ship duplicate META-INF entries. @@ -147,7 +150,7 @@ android { } } -// Export Room schemas so migrations can be validated by instrumented MigrationTestHelper tests. +// Export Room schemas so the instrumented MigrationTest can replay and validate each migration. ksp { arg("room.schemaLocation", "$projectDir/schemas") } @@ -196,6 +199,11 @@ dependencies { ksp(libs.androidx.room.compiler) implementation(libs.sqlcipher.android) + // Raise kotlinx-serialization to the version Room's schema-bundle serializers were compiled + // against (see libs.versions.toml). AGP 9 consistent resolution shares it with the androidTest + // classpath so MigrationTestHelper can parse the exported schema JSON. + implementation(platform(libs.kotlinx.serialization.bom)) + testImplementation(libs.junit) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) @@ -210,4 +218,5 @@ dependencies { androidTestImplementation(libs.androidx.espresso.intents) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.room.testing) } diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt index 61ce90d..a50c410 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt @@ -22,9 +22,9 @@ import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.ServerConfigEmbedded /** - * Schema-behavior tests on the real (v7) Room database. (Migrations from versions before - * exportSchema was enabled can't be replayed with MigrationTestHelper, since their schema JSONs - * were never exported; exportSchema is now on so future migrations can be tested.) + * Schema-behavior tests on a fresh in-memory database at the current version. The migration DDL + * itself is exercised by [MigrationTest], which replays the schema chain exported to app/schemas. + * (Migrations from before v7 predate schema export, so they can't be replayed there.) */ @RunWith(AndroidJUnit4::class) class LibreMailDatabaseTest { diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt new file mode 100644 index 0000000..076e821 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import androidx.room.migration.Migration +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Modifier + +/** + * Replays the app's Room migrations against the schema JSONs exported to `app/schemas` (shipped to + * the test APK as assets). DatabaseModule deliberately registers no destructive fallback — dropping + * tables would lose stored accounts and credentials — so a drifted migration crashes upgrading + * users at first database open; these tests make such drift fail in CI instead (issue #63). + * + * The migration list is discovered from Migrations.kt and the target version from the newest + * exported schema, so a future migration is covered automatically: author the `Migration`, register + * it in DatabaseModule, commit the new schema JSON, and [migratingFromV7ReplaysEveryMigrationAndPreservesData] + * replays it with no edit here. (Only v7+ can be replayed — older versions predate schema export.) + */ +@RunWith(AndroidJUnit4::class) +class MigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + LibreMailDatabase::class.java, + emptyList(), + FrameworkSQLiteOpenHelperFactory(), + ) + + /** v11 -> v12 (PR #54): `folders.specialUse` appears defaulting to 0 and existing data survives. */ + @Test + fun migrate11To12_defaultsExistingFoldersToNotSpecialUse() { + helper.createDatabase(TEST_DB, 11).apply { + insertAccount() + execSQL( + "INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) VALUES " + + "('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0), " + + "('acct', '[Gmail]/Sent Mail', 'Sent Mail', 'SENT', 1, 1)", + ) + execSQL( + "INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " + + "timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched) " + + "VALUES ('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " + + "'INBOX', 1, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 12, true, MIGRATION_11_12) + + db.query("SELECT fullName, specialUse FROM folders ORDER BY sortOrder").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("INBOX", c.getString(0)) + assertEquals(0, c.getInt(1)) + assertTrue(c.moveToNext()) + assertEquals("[Gmail]/Sent Mail", c.getString(0)) + assertEquals(0, c.getInt(1)) + assertFalse("both pre-upgrade folder rows must survive, and nothing else", c.moveToNext()) + } + // The folders' account and cached mail are untouched. + assertEquals(1, db.count("accounts")) + assertEquals(1, db.count("messages")) + db.close() + } + + /** + * A gap in the chain (or a migration whose target schema was never committed) crashes upgrading + * users, so fail fast with a readable message before replaying anything. + */ + @Test + fun migrationsFormOneUnbrokenChainUpToTheLatestExportedSchema() { + assertEquals( + "Migrations.kt must chain every version step up to the newest exported schema " + + "(DatabaseModule registers no destructive fallback, so a gap crashes upgrades)", + (1 until latestExportedSchemaVersion()).map { it to it + 1 }, + allAppMigrations.map { it.startVersion to it.endVersion }, + ) + } + + /** + * Creates a database at v7 (the oldest exported schema), fills it like a used install, then + * replays every migration one step at a time — `runMigrationsAndValidate` diffs the migrated + * schema against each version's exported JSON, so a failure names the exact step that drifted. + */ + @Test + fun migratingFromV7ReplaysEveryMigrationAndPreservesData() { + helper.createDatabase(TEST_DB, OLDEST_EXPORTED_SCHEMA).apply { + seedVersion7Cache() + close() + } + + var open: SupportSQLiteDatabase? = null + for (migration in allAppMigrations.filter { it.startVersion >= OLDEST_EXPORTED_SCHEMA }) { + open?.close() + val stepDb = helper.runMigrationsAndValidate(TEST_DB, migration.endVersion, true, migration) + stepDb.writeMidChainData() + open = stepDb + } + val db = checkNotNull(open) { "no migration starts at v$OLDEST_EXPORTED_SCHEMA" } + + assertEquals("the chain must end at the newest exported schema", latestExportedSchemaVersion(), db.version) + db.assertVersion7CacheSurvived() + db.assertMigrationBackfillsApplied() + db.close() + } + + /** The newest schema JSON exported to app/schemas (shipped to the test APK as assets). */ + private fun latestExportedSchemaVersion(): Int { + val schemaFolder = checkNotNull(LibreMailDatabase::class.java.canonicalName) + val versions = InstrumentationRegistry.getInstrumentation().context.assets.list(schemaFolder) + .orEmpty() + .mapNotNull { it.removeSuffix(".json").toIntOrNull() } + check(versions.isNotEmpty()) { "no exported schemas under androidTest assets/$schemaFolder" } + return versions.max() + } + + /** The accounts table has kept this shape since before v7, so every test can share one insert. */ + private fun SupportSQLiteDatabase.insertAccount() { + execSQL( + "INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " + + "smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " + + "'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')", + ) + } + + /** Fills a v7 database the way a used install would look (columns exactly as in 7.json). */ + private fun SupportSQLiteDatabase.seedVersion7Cache() { + insertAccount() + execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')") + execSQL( + "INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " + + "timestampMillis, isRead, isStarred, inInbox, bodyFetched) VALUES " + + "('acct:1', 'acct', 'Ada', 'ada@example.org', 'Analytical engines', 'Dear Charles', " + + "'Dear Charles, the mill works.', 0, 1000, 1, 0, 1, 1), " + + "('acct:2', 'acct', 'Charles', 'charles@example.org', 'Re: engines', '', '', 0, 2000, 0, 1, 1, 0)", + ) + execSQL( + "INSERT INTO attachments (messageId, partIndex, filename, mimeType, sizeBytes) " + + "VALUES ('acct:1', 0, 'notes.pdf', 'application/pdf', 2048)", + ) + execSQL( + "INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, subject, body, createdAt, " + + "lastError) VALUES ('out-1', 'acct', 'charles@example.org', '', 'Queued', 'Body', 3000, NULL)", + ) + execSQL( + "INSERT INTO drafts (id, accountId, toAddresses, ccAddresses, subject, body, updatedAt, " + + "attachments) VALUES ('draft-1', 'acct', 'charles@example.org', '', 'Draft', 'Text', 4000, '')", + ) + } + + /** Rows a user would write at intermediate versions; they must survive the rest of the chain. */ + private fun SupportSQLiteDatabase.writeMidChainData() { + when (version) { + // v8 is the first version with a folders table; 11->12 must stamp this row specialUse = 0. + 8 -> execSQL( + "INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) " + + "VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0)", + ) + // A signature saved by a v9 user: 10->11 must carry it into the new signatures table. + 9 -> execSQL( + "UPDATE account_settings SET signature = 'Cheers,' || char(10) || 'Ada' " + + "WHERE accountId = 'acct'", + ) + } + } + + /** Every row cached at v7 must still be present and correct at the end of the chain. */ + private fun SupportSQLiteDatabase.assertVersion7CacheSurvived() { + assertEquals(1, count("accounts")) + assertEquals(2, count("messages")) + assertEquals(1, count("outbox")) + assertEquals(1, count("drafts")) + query("SELECT folder, subject, isRead FROM messages WHERE id = 'acct:1'").use { c -> + assertTrue("message cached at v7 must survive", c.moveToFirst()) + assertEquals("7->8 files pre-upgrade messages under INBOX", "INBOX", c.getString(0)) + assertEquals("Analytical engines", c.getString(1)) + assertEquals(1, c.getInt(2)) + } + query("SELECT encryptedSecret FROM credentials WHERE accountId = 'acct'").use { c -> + assertTrue("stored credentials must never be dropped by a migration", c.moveToFirst()) + assertEquals("sealed-secret", c.getString(0)) + } + query("SELECT filename FROM attachments WHERE messageId = 'acct:1'").use { c -> + assertTrue("attachment rows must survive the 6->7 style table rebuilds", c.moveToFirst()) + assertEquals("notes.pdf", c.getString(0)) + } + } + + /** Columns and rows created by the migrations themselves must hold their documented defaults. */ + private fun SupportSQLiteDatabase.assertMigrationBackfillsApplied() { + // 8->9 backfills one default settings row per existing account. + query("SELECT signatureEnabled, notificationsEnabled FROM account_settings").use { c -> + assertTrue("8->9 must backfill a settings row for the v7 account", c.moveToFirst()) + assertEquals(1, c.getInt(0)) + assertEquals(1, c.getInt(1)) + } + // 9->10 adds bcc columns defaulting to ''; 10->11 adds nullable bodyHtml. + query("SELECT bccAddresses, bodyHtml FROM outbox WHERE id = 'out-1'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("", c.getString(0)) + assertTrue("bodyHtml must default to null (plaintext-only)", c.isNull(1)) + } + query("SELECT bccAddresses, bodyHtml FROM drafts WHERE id = 'draft-1'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("", c.getString(0)) + assertTrue(c.isNull(1)) + } + // 10->11 turns the signature written at v9 into that account's default rich-text signature. + query("SELECT name, contentHtml, isDefault FROM signatures WHERE accountId = 'acct'").use { c -> + assertTrue("10->11 must backfill the legacy per-account signature", c.moveToFirst()) + assertEquals("Signature", c.getString(0)) + assertEquals("Cheers,
Ada", c.getString(1)) + assertEquals(1, c.getInt(2)) + assertFalse("exactly one signature row must be backfilled", c.moveToNext()) + } + // 11->12 stamps the folder cached at v8 as not special-use. + query("SELECT specialUse FROM folders WHERE fullName = 'INBOX'").use { c -> + assertTrue("folder cached at v8 must survive to the newest version", c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + } + + private fun SupportSQLiteDatabase.count(table: String): Int = query("SELECT COUNT(*) FROM $table").use { c -> + c.moveToFirst() + c.getInt(0) + } + + private companion object { + const val TEST_DB = "migration-test.db" + + /** The oldest schema in app/schemas; earlier versions predate schema export. */ + const val OLDEST_EXPORTED_SCHEMA = 7 + + /** + * Every migration the app ships, pulled from Migrations.kt's file facade so a newly added + * migration is replayed automatically. DatabaseModule.provideDatabase registers this same + * set of top-level vals. + */ + val allAppMigrations: List = run { + val migrationsFile = checkNotNull(MIGRATION_1_2.javaClass.enclosingClass) { + "expected MIGRATION_1_2 to be a top-level val in Migrations.kt" + } + migrationsFile.methods + .filter { Modifier.isStatic(it.modifiers) && it.parameterCount == 0 } + .filter { Migration::class.java.isAssignableFrom(it.returnType) } + .map { it.invoke(null) as Migration } + .sortedBy { it.startVersion } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3b1b1e..9d72eef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,10 @@ sqlcipher = "4.16.0" datastore = "1.2.1" work = "2.11.2" coroutines = "1.10.2" +# Forced above androidx.savedstate's transitive 1.7.3 (shared with the androidTest classpath via +# AGP 9 consistent resolution); Room's schema-bundle serializers need >= 1.8.0 or MigrationTestHelper +# throws AbstractMethodError on GeneratedSerializer.typeParametersSerializers(). +kotlinxSerialization = "1.8.1" appauth = "0.11.1" angusMail = "2.0.5" junit = "4.13.2" @@ -63,6 +67,8 @@ androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", vers androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +# Room MigrationTestHelper (instrumented migration tests). +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } # SQLCipher — opt-in at-rest encryption of the Room cache. sqlcipher-android = { group = "net.zetetic", name = "sqlcipher-android", version.ref = "sqlcipher" } @@ -74,6 +80,10 @@ androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx" kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +# Serialization BOM — imported as a platform (not used directly) to raise the transitive +# kotlinx-serialization runtime to the version Room's schema bundles were compiled against. +kotlinx-serialization-bom = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-bom", version.ref = "kotlinxSerialization" } + # Email transport / OAuth — wired in later increments appauth = { group = "net.openid", name = "appauth", version.ref = "appauth" } angus-mail = { group = "org.eclipse.angus", name = "angus-mail", version.ref = "angusMail" } -- 2.47.3