From 0d3b0e2a735f078933bcd4bd6bd6370b8a153ed7 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 18:32:36 -0500 Subject: [PATCH] =?UTF-8?q?test(coverage):=20lane=203=20=E2=80=94=20persis?= =?UTF-8?q?tence,=20DAOs=20&=20migrations=20instrumented=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instrumented (androidTest) coverage for data/local: Room DAO queries/mutations, every exported-schema migration, and DatabaseProvisioner/DatabaseEncryption (SQLCipher) provisioning branches, incl. a regression guard for the SQLCipher System.loadLibrary cold-start crash (592a797). Validated locally: 114/181 instrumented tests passed, 0 failed, via connectedDebugAndroidTest on a manually-provisioned api36 emulator. The local GMD emulator wedges mid-suite (~112) on this machine (see #269); CI validates the full 181 on its own runners. Closes #248 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 3 + .../libremail/data/local/AccountDaoTest.kt | 87 +++++ .../data/local/AccountSettingsDaoTest.kt | 91 +++++ .../libremail/data/local/AttachmentDaoTest.kt | 124 +++++++ .../data/local/BackfillProgressDaoTest.kt | 84 +++++ .../libremail/data/local/CredentialDaoTest.kt | 64 ++++ .../data/local/DatabaseEncryptionTest.kt | 84 +++++ .../DatabaseProvisionerInstrumentedTest.kt | 169 ++++++++++ .../local/DeferredOpenHelperFactoryTest.kt | 94 ++++++ .../org/libremail/data/local/DraftDaoTest.kt | 89 +++++ .../org/libremail/data/local/FolderDaoTest.kt | 78 +++++ .../libremail/data/local/MessageDaoTest.kt | 310 ++++++++++++++++++ .../org/libremail/data/local/MigrationTest.kt | 164 +++++++++ .../org/libremail/data/local/OutboxDaoTest.kt | 97 ++++++ .../libremail/data/local/SignatureDaoTest.kt | 153 +++++++++ gradle/libs.versions.toml | 2 + 16 files changed, 1693 insertions(+) create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/AccountDaoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/AccountSettingsDaoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/AttachmentDaoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/BackfillProgressDaoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/CredentialDaoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/DeferredOpenHelperFactoryTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/DraftDaoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/FolderDaoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/MessageDaoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/OutboxDaoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/SignatureDaoTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b4d6630..b9fd643 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -324,4 +324,7 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.room.testing) + // Instrumented DatabaseProvisioner test: fakes the security/settings collaborators and spies the + // DatabaseEncryption object to regression-guard the SQLCipher native-lib load before a keyed open. + androidTestImplementation(libs.mockk.android) } diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/AccountDaoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/AccountDaoTest.kt new file mode 100644 index 0000000..938c457 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/AccountDaoTest.kt @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.ServerConfigEmbedded + +/** + * Real-SQLite behavior of [AccountDao] in the non-auth [AccountDatabase]: the email-ordered + * list/observe reads, point lookup, upsert-on-conflict, and deletion. + */ +@RunWith(AndroidJUnit4::class) +class AccountDaoTest { + + private lateinit var db: AccountDatabase + private lateinit var dao: AccountDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build() + dao = db.accountDao() + } + + @After + fun tearDown() = db.close() + + private fun account(id: String, email: String, displayName: String = "Name") = AccountEntity( + id = id, + email = email, + displayName = displayName, + authType = "PASSWORD_IMAP", + imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"), + smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"), + ) + + @Test + fun observeAllAndGetAllReturnAccountsOrderedByEmail() = runBlocking { + dao.upsert(account("2", "zed@example.org")) + dao.upsert(account("1", "ada@example.org")) + + assertEquals(listOf("ada@example.org", "zed@example.org"), dao.observeAll().first().map { it.email }) + assertEquals(listOf("ada@example.org", "zed@example.org"), dao.getAll().map { it.email }) + } + + @Test + fun getByIdReturnsTheAccountOrNullAndEmbedsServerConfig() = runBlocking { + dao.upsert(account("acct", "ada@example.org")) + + val stored = dao.getById("acct") + assertEquals("ada@example.org", stored?.email) + assertEquals(993, stored?.imap?.port) + assertEquals("smtp.example.org", stored?.smtp?.host) + assertNull(dao.getById("absent")) + } + + @Test + fun upsertReplacesAnAccountWithTheSameId() = runBlocking { + dao.upsert(account("acct", "ada@example.org", displayName = "Ada")) + + dao.upsert(account("acct", "ada@example.org", displayName = "Ada Lovelace")) + + assertEquals("Ada Lovelace", dao.getById("acct")?.displayName) + assertEquals(1, dao.getAll().size) + } + + @Test + fun deleteByIdRemovesTheAccount() = runBlocking { + dao.upsert(account("acct", "ada@example.org")) + + dao.deleteById("acct") + + assertNull(dao.getById("acct")) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/AccountSettingsDaoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/AccountSettingsDaoTest.kt new file mode 100644 index 0000000..9cf3e6b --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/AccountSettingsDaoTest.kt @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.AccountSettingsDao +import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.AccountSettingsEntity +import org.libremail.data.local.entity.ServerConfigEmbedded + +/** + * Real-SQLite behavior of [AccountSettingsDao] in [AccountDatabase]: the live observer (which emits + * null before a row exists), the one-shot read, upsert-on-conflict, and the nullable retention + * overrides round-tripping. A parent account row is inserted first because the settings table + * foreign-keys to `accounts`. + */ +@RunWith(AndroidJUnit4::class) +class AccountSettingsDaoTest { + + private lateinit var db: AccountDatabase + private lateinit var dao: AccountSettingsDao + private lateinit var accountDao: AccountDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build() + dao = db.accountSettingsDao() + accountDao = db.accountDao() + } + + @After + fun tearDown() = db.close() + + private suspend fun insertAccount(id: String = "acct") = accountDao.upsert( + AccountEntity( + id = id, + email = "$id@example.org", + displayName = "Name", + authType = "PASSWORD_IMAP", + imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"), + smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"), + ), + ) + + @Test + fun observeEmitsNullBeforeARowExistsThenTheRow() = runBlocking { + insertAccount() + assertNull("no settings row yet -> the observer emits null", dao.observe("acct").first()) + + dao.upsert(AccountSettingsEntity("acct", signature = "Cheers")) + + assertEquals("Cheers", dao.observe("acct").first()?.signature) + } + + @Test + fun getReturnsTheStoredRowOrNull() = runBlocking { + insertAccount() + dao.upsert(AccountSettingsEntity("acct", signatureEnabled = false, notificationsEnabled = false)) + + val stored = dao.get("acct") + assertEquals(false, stored?.signatureEnabled) + assertEquals(false, stored?.notificationsEnabled) + assertNull(dao.get("absent")) + } + + @Test + fun upsertReplacesTheSettingsAndRoundTripsNullableRetentionOverrides() = runBlocking { + insertAccount() + dao.upsert(AccountSettingsEntity("acct", retentionCount = null, retentionMonths = null)) + assertNull(dao.get("acct")?.retentionCount) + assertNull(dao.get("acct")?.retentionMonths) + + dao.upsert(AccountSettingsEntity("acct", retentionCount = 500, retentionMonths = 6)) + + val stored = dao.get("acct") + assertEquals(500, stored?.retentionCount) + assertEquals(6, stored?.retentionMonths) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/AttachmentDaoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/AttachmentDaoTest.kt new file mode 100644 index 0000000..33cd0ab --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/AttachmentDaoTest.kt @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.dao.AttachmentDao +import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.entity.AttachmentEntity +import org.libremail.data.local.entity.MessageEntity + +/** + * Real-SQLite behavior of [AttachmentDao]: the `partIndex` ordering, the inline-image filter shared + * with [LibreMailDatabaseTest], REPLACE-on-conflict inserts, and the delete/replace transaction. A + * parent message row is inserted first because the attachments table foreign-keys to `messages` + * (Room enables `PRAGMA foreign_keys = ON`). + */ +@RunWith(AndroidJUnit4::class) +class AttachmentDaoTest { + + private lateinit var db: LibreMailDatabase + private lateinit var dao: AttachmentDao + private lateinit var messageDao: MessageDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build() + dao = db.attachmentDao() + messageDao = db.messageDao() + } + + @After + fun tearDown() = db.close() + + private suspend fun insertParent(id: String) = messageDao.insertNew( + listOf( + MessageEntity( + id = id, + accountId = "acct", + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "", + body = "", + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + ), + ), + ) + + @Test + fun getForMessageReturnsEveryPartOrderedByPartIndex() = runBlocking { + insertParent("m1") + dao.insert( + listOf( + AttachmentEntity("m1", 2, "third.pdf", "application/pdf", 30), + AttachmentEntity("m1", 0, "first.pdf", "application/pdf", 10), + AttachmentEntity("m1", 1, "second.png", "image/png", 20, contentId = "cid1"), + ), + ) + + // getForMessage keeps inline (cid) parts and orders by partIndex ascending. + assertEquals( + listOf("first.pdf", "second.png", "third.pdf"), + dao.getForMessage("m1").map { it.filename }, + ) + } + + @Test + fun insertReplacesAPartWithTheSamePrimaryKey() = runBlocking { + insertParent("m1") + dao.insert(listOf(AttachmentEntity("m1", 0, "old.pdf", "application/pdf", 10))) + + // Same (messageId, partIndex) -> REPLACE overwrites the earlier row. + dao.insert(listOf(AttachmentEntity("m1", 0, "new.pdf", "application/pdf", 99))) + + val parts = dao.getForMessage("m1") + assertEquals(1, parts.size) + assertEquals("new.pdf", parts.single().filename) + assertEquals(99L, parts.single().sizeBytes) + } + + @Test + fun deleteForMessageRemovesOnlyThatMessagesParts() = runBlocking { + insertParent("m1") + insertParent("m2") + dao.insert(listOf(AttachmentEntity("m1", 0, "a.pdf", "application/pdf", 1))) + dao.insert(listOf(AttachmentEntity("m2", 0, "b.pdf", "application/pdf", 1))) + + dao.deleteForMessage("m1") + + assertTrue(dao.getForMessage("m1").isEmpty()) + assertEquals(listOf("b.pdf"), dao.getForMessage("m2").map { it.filename }) + } + + @Test + fun replaceForMessageSwapsTheWholeAttachmentSetInOneTransaction() = runBlocking { + insertParent("m1") + dao.insert( + listOf( + AttachmentEntity("m1", 0, "old-a.pdf", "application/pdf", 1), + AttachmentEntity("m1", 1, "old-b.pdf", "application/pdf", 2), + ), + ) + + dao.replaceForMessage( + "m1", + listOf(AttachmentEntity("m1", 0, "fresh.pdf", "application/pdf", 3)), + ) + + assertEquals(listOf("fresh.pdf"), dao.observeForMessage("m1").first().map { it.filename }) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/BackfillProgressDaoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/BackfillProgressDaoTest.kt new file mode 100644 index 0000000..caa11f5 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/BackfillProgressDaoTest.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.dao.BackfillProgressDao +import org.libremail.data.local.entity.BackfillProgressEntity + +/** + * Real-SQLite behavior of [BackfillProgressDao] — the per-(account, folder) paging boundary the + * full-history backfill persists so it resumes after process death: point read, upsert-on-conflict, + * account-scoped clear, and the global reset used when the retention default changes. + */ +@RunWith(AndroidJUnit4::class) +class BackfillProgressDaoTest { + + private lateinit var db: LibreMailDatabase + private lateinit var dao: BackfillProgressDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build() + dao = db.backfillProgressDao() + } + + @After + fun tearDown() = db.close() + + @Test + fun getReturnsTheStoredBoundaryOrNull() = runBlocking { + dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 42, complete = false)) + + val stored = dao.get("acct", "INBOX") + assertEquals(42L, stored?.nextBeforeUid) + assertEquals(false, stored?.complete) + assertNull(dao.get("acct", "Archive")) + } + + @Test + fun upsertReplacesTheBoundaryForTheSameAccountAndFolder() = runBlocking { + dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 100, complete = false)) + + // A later page lowers the boundary and can mark the folder complete. + dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 10, complete = true)) + + val stored = dao.get("acct", "INBOX") + assertEquals(10L, stored?.nextBeforeUid) + assertEquals(true, stored?.complete) + } + + @Test + fun deleteForAccountClearsOnlyThatAccountsProgress() = runBlocking { + dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 1)) + dao.upsert(BackfillProgressEntity("acct", "Archive", nextBeforeUid = 2)) + dao.upsert(BackfillProgressEntity("acct2", "INBOX", nextBeforeUid = 3)) + + dao.deleteForAccount("acct") + + assertNull(dao.get("acct", "INBOX")) + assertNull(dao.get("acct", "Archive")) + assertEquals(3L, dao.get("acct2", "INBOX")?.nextBeforeUid) + } + + @Test + fun deleteAllClearsEveryAccountsProgress() = runBlocking { + dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 1)) + dao.upsert(BackfillProgressEntity("acct2", "INBOX", nextBeforeUid = 2)) + + dao.deleteAll() + + assertNull(dao.get("acct", "INBOX")) + assertNull(dao.get("acct2", "INBOX")) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/CredentialDaoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/CredentialDaoTest.kt new file mode 100644 index 0000000..41981e4 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/CredentialDaoTest.kt @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.dao.CredentialDao +import org.libremail.data.local.entity.CredentialEntity + +/** + * Real-SQLite behavior of [CredentialDao] in [AccountDatabase]: the point read, upsert-on-conflict + * (a rotated secret overwrites the old one), and deletion. The credentials table is not foreign-keyed + * to `accounts`, so these rows stand alone. + */ +@RunWith(AndroidJUnit4::class) +class CredentialDaoTest { + + private lateinit var db: AccountDatabase + private lateinit var dao: CredentialDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build() + dao = db.credentialDao() + } + + @After + fun tearDown() = db.close() + + @Test + fun getByIdReturnsTheSecretOrNull() = runBlocking { + dao.upsert(CredentialEntity("acct", "sealed-secret")) + + assertEquals("sealed-secret", dao.getById("acct")?.encryptedSecret) + assertNull(dao.getById("absent")) + } + + @Test + fun upsertReplacesTheSecretForTheSameAccount() = runBlocking { + dao.upsert(CredentialEntity("acct", "old")) + + dao.upsert(CredentialEntity("acct", "rotated")) + + assertEquals("rotated", dao.getById("acct")?.encryptedSecret) + } + + @Test + fun deleteByIdRemovesTheCredential() = runBlocking { + dao.upsert(CredentialEntity("acct", "sealed-secret")) + + dao.deleteById("acct") + + assertNull(dao.getById("acct")) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt index 0e0a82d..325f813 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt @@ -7,6 +7,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SQLiteDatabase import net.zetetic.database.sqlcipher.SupportOpenHelperFactory import org.junit.After import org.junit.Assert.assertEquals @@ -66,6 +67,89 @@ class DatabaseEncryptionTest { } } + @Test + fun isEncryptedIsFalseForMissingEmptyAndPlaintextFiles() = runBlocking { + val missing = File(dbFile.parentFile, "$dbName.missing") + assertFalse("a non-existent file is not encrypted", DatabaseEncryption.isEncrypted(missing)) + + val empty = File(dbFile.parentFile, "$dbName.empty") + empty.delete() + empty.createNewFile() + assertFalse("a zero-length file is not encrypted", DatabaseEncryption.isEncrypted(empty)) + + openPlaintext().apply { + messageDao().insertNew(listOf(message("acct:1"))) + close() + } + assertFalse("a plaintext SQLite file is not encrypted", DatabaseEncryption.isEncrypted(dbFile)) + } + + @Test + fun ensureEncryptedNoOpsOnMissingEmptyOrAlreadyEncryptedFiles() = runBlocking { + // Missing / empty: nothing to convert (the factory creates a fresh DB encrypted). + val empty = File(dbFile.parentFile, "$dbName.empty") + empty.createNewFile() + DatabaseEncryption.ensureEncrypted(File(dbFile.parentFile, "$dbName.missing"), passphrase) + DatabaseEncryption.ensureEncrypted(empty, passphrase) + + openPlaintext().apply { + messageDao().insertNew(listOf(message("acct:1"))) + close() + } + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + assertTrue(DatabaseEncryption.isEncrypted(dbFile)) + + // A second call on an already-encrypted file is an idempotent no-op; the data stays readable. + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + assertTrue("the file stays encrypted", DatabaseEncryption.isEncrypted(dbFile)) + openEncrypted().apply { + assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id }) + close() + } + } + + @Test + fun ensurePlaintextNoOpsOnAnAlreadyPlaintextFile() = runBlocking { + openPlaintext().apply { + messageDao().insertNew(listOf(message("acct:1"))) + close() + } + + // Already plaintext: decrypt must be a no-op (and must not corrupt the file). + DatabaseEncryption.ensurePlaintext(dbFile, passphrase) + + assertFalse(DatabaseEncryption.isEncrypted(dbFile)) + openPlaintext().apply { + assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id }) + close() + } + } + + @Test + fun schemaVersionIsCarriedOntoTheEncryptedFile() = runBlocking { + // A first Room open stamps PRAGMA user_version to the schema version; the conversion must carry + // it across (sqlcipher_export copies tables but not that pragma), or Room would attempt a bogus + // migration on the re-keyed file. + openPlaintext().apply { + messageDao().insertNew(listOf(message("acct:1"))) + close() + } + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + + val encrypted = SQLiteDatabase.openOrCreateDatabase( + dbFile.absolutePath, + passphrase.toByteArray(Charsets.US_ASCII), + null, + null, + ) + val version = try { + encrypted.version + } finally { + encrypted.close() + } + assertEquals("Room's schema version must survive the plaintext -> encrypted conversion", 18, version) + } + private fun openPlaintext(): LibreMailDatabase = Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build() diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt new file mode 100644 index 0000000..f3c5bbd --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.entity.MessageEntity +import org.libremail.data.security.DatabaseKeyStore +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import java.io.File + +/** + * On-device behavior of [DatabaseProvisioner.prepareCache] against REAL SQLCipher (the JVM + * `DatabaseProvisionerTest` mocks [DatabaseEncryption], so it can't exercise a real keyed open). The + * security/settings collaborators are faked; [DatabaseEncryption] is a spy so its real conversions run + * while the native-lib load can still be verified. + * + * The headline is the steady-state regression guard for the crash fixed in 592a797: with the cache + * already encrypted (nothing to convert) `ensureEncrypted` no-ops, so the provisioner itself must load + * SQLCipher's native library before the keyed open — otherwise Room's `nativeOpen` throws + * `UnsatisfiedLinkError` on every cold start. The `verify(exactly = 1) { ensureNativeLibraryLoaded() }` + * fails if that explicit load is ever removed. + */ +@RunWith(AndroidJUnit4::class) +class DatabaseProvisionerInstrumentedTest { + + private val appContext = ApplicationProvider.getApplicationContext() + private val dbName = "provisioner_instrumented_test.db" + private val dbFile: File get() = appContext.getDatabasePath(dbName) + + // 64 hex chars == a 32-byte SQLCipher passphrase. + private val passphrase = "0123456789abcdef".repeat(4) + + private val keyStore = mockk() + private val settingsRepository = mockk() + private val migrator = mockk() + private val context = mockk() + + @Before + fun setUp() { + clean() + every { context.getDatabasePath(DatabaseFiles.NAME) } returns dbFile + coEvery { keyStore.isClearPending() } returns false + coEvery { keyStore.resolvePassphrase(any()) } returns passphrase + coEvery { migrator.migrateIfNeeded() } just Runs + } + + @After + fun tearDown() { + unmockkAll() + clean() + } + + private fun clean() { + appContext.deleteDatabase(dbName) + dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() } + } + + private fun provisioner() = DatabaseProvisioner(context, keyStore, settingsRepository, migrator, Dispatchers.IO) + + private fun seedPlaintextRow() { + Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName).build().apply { + runBlocking { messageDao().insertNew(listOf(message("acct:1"))) } + close() + } + } + + private fun openEncrypted(): LibreMailDatabase = + Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName) + .openHelperFactory(SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false)) + .build() + + private fun openPlaintext(): LibreMailDatabase = + Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName).build() + + private fun message(id: String) = MessageEntity( + id = id, + accountId = "acct", + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "", + body = "", + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + ) + + @Test + fun steadyStateEncryptedStartLoadsTheNativeLibAndOpensKeyedWithoutCrashing() = runBlocking { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false)) + // Build a genuinely-encrypted, steady-state cache. Encrypt BEFORE spying so the conversion is a + // real one; by the time prepareCache runs, ensureEncrypted has nothing left to do. + seedPlaintextRow() + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + assertTrue("precondition: the cache is already encrypted", DatabaseEncryption.isEncrypted(dbFile)) + + mockkObject(DatabaseEncryption) // spy: real implementations still run + val mode = provisioner().prepareCache() + + assertEquals(CacheOpenMode.Encrypted(passphrase), mode) + // Regression guard (592a797): a steady-state encrypted start converts nothing, so the provisioner + // MUST load the native library itself before the keyed open below. Fails if that load is removed. + verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() } + unmockkObject(DatabaseEncryption) + + // The keyed open the provisioner reported must actually succeed on real SQLCipher (no crash). + openEncrypted().apply { + assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id }) + close() + } + } + + @Test + fun encryptionTurnedOffDecryptsAnEncryptedCacheToPlaintext() = runBlocking { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false)) + seedPlaintextRow() + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + assertTrue("precondition: the cache starts encrypted", DatabaseEncryption.isEncrypted(dbFile)) + + val mode = provisioner().prepareCache() + + assertEquals(CacheOpenMode.Plaintext, mode) + assertFalse("the cache must be decrypted so the unkeyed open works", DatabaseEncryption.isEncrypted(dbFile)) + openPlaintext().apply { + assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id }) + close() + } + } + + @Test + fun plaintextStartLeavesThePlaintextCacheUntouched() = runBlocking { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false)) + seedPlaintextRow() + assertFalse("precondition: the cache is plaintext", DatabaseEncryption.isEncrypted(dbFile)) + + val mode = provisioner().prepareCache() + + assertEquals(CacheOpenMode.Plaintext, mode) + assertFalse("a plaintext-with-encryption-off start converts nothing", DatabaseEncryption.isEncrypted(dbFile)) + openPlaintext().apply { + assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id }) + close() + } + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DeferredOpenHelperFactoryTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DeferredOpenHelperFactoryTest.kt new file mode 100644 index 0000000..19a636d --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DeferredOpenHelperFactoryTest.kt @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Behavior of [DeferredOpenHelperFactory] (issue #93): Room touches `create` and + * `setWriteAheadLoggingEnabled` on the injection path (possibly the main thread), so the REAL open + * helper — and the blocking startup gate that builds it — must be deferred until the database is first + * actually opened. These tests pin that laziness, the WAL memoization, and the "never open just to + * close" contract using a recording fake delegate (no real database needed). + */ +@RunWith(AndroidJUnit4::class) +class DeferredOpenHelperFactoryTest { + + private val context = ApplicationProvider.getApplicationContext() + + private class FakeOpenHelper(private val db: SupportSQLiteDatabase) : SupportSQLiteOpenHelper { + var walEnabled: Boolean? = null + var closed = false + override val databaseName: String = "fake" + override fun setWriteAheadLoggingEnabled(enabled: Boolean) { + walEnabled = enabled + } + + override val writableDatabase: SupportSQLiteDatabase get() = db + override val readableDatabase: SupportSQLiteDatabase get() = db + override fun close() { + closed = true + } + } + + private fun configuration(name: String): SupportSQLiteOpenHelper.Configuration = + SupportSQLiteOpenHelper.Configuration.builder(context) + .name(name) + .callback(object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + }) + .build() + + @Test + fun theDelegateIsBuiltLazilyOnFirstOpenAndThenMemoized() { + var builds = 0 + val db = mockk(relaxed = true) + val fake = FakeOpenHelper(db) + val helper = DeferredOpenHelperFactory { + builds++ + fake + }.create(configuration("deferred-lazy")) + + // create() must not build the real delegate (it runs on the possibly-main injection thread). + assertEquals("create() must not build the delegate", 0, builds) + assertEquals("deferred-lazy", helper.databaseName) + + // WAL can be set before the first open; it must be remembered, not force an early build. + helper.setWriteAheadLoggingEnabled(true) + assertEquals("setWriteAheadLoggingEnabled must not build the delegate", 0, builds) + + // The first open builds the delegate and applies the remembered WAL setting. + assertSame(db, helper.writableDatabase) + assertEquals(1, builds) + assertEquals("the remembered WAL flag is applied when the delegate is built", true, fake.walEnabled) + + // Subsequent opens reuse the same delegate. + assertSame(db, helper.readableDatabase) + assertEquals("the delegate is memoized after the first open", 1, builds) + } + + @Test + fun closeBeforeAnyOpenNeverBuildsTheDelegate() { + var builds = 0 + val fake = FakeOpenHelper(mockk(relaxed = true)) + val helper = DeferredOpenHelperFactory { + builds++ + fake + }.create(configuration("deferred-close")) + + helper.close() + + assertEquals("close() must not build a delegate just to close it", 0, builds) + assertFalse("a never-built delegate is never closed", fake.closed) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DraftDaoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DraftDaoTest.kt new file mode 100644 index 0000000..03cade7 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DraftDaoTest.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.dao.DraftDao +import org.libremail.data.local.entity.DraftEntity + +/** + * Real-SQLite behavior of [DraftDao] — the saved-but-unsent store the compose autosave writes: + * newest-first observation by `updatedAt`, the live count, point/bulk reads, upsert-on-conflict, and + * deletion. + */ +@RunWith(AndroidJUnit4::class) +class DraftDaoTest { + + private lateinit var db: LibreMailDatabase + private lateinit var dao: DraftDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build() + dao = db.draftDao() + } + + @After + fun tearDown() = db.close() + + private fun draft(id: String, updatedAt: Long = 1_000L, subject: String = "Draft $id") = DraftEntity( + id = id, + accountId = "acct", + toAddresses = "bob@example.org", + ccAddresses = "", + subject = subject, + body = "Body", + updatedAt = updatedAt, + ) + + @Test + fun observeAllReturnsDraftsNewestFirstAndObserveCountTracksThem() = runBlocking { + dao.upsert(draft("old", updatedAt = 100)) + dao.upsert(draft("new", updatedAt = 300)) + dao.upsert(draft("mid", updatedAt = 200)) + + assertEquals(listOf("new", "mid", "old"), dao.observeAll().first().map { it.id }) + assertEquals(3, dao.observeCount().first()) + } + + @Test + fun getByIdAndGetAllReadStoredDrafts() = runBlocking { + dao.upsert(draft("d1")) + dao.upsert(draft("d2")) + + assertEquals("Draft d1", dao.getById("d1")?.subject) + assertNull(dao.getById("absent")) + assertEquals(setOf("d1", "d2"), dao.getAll().map { it.id }.toSet()) + } + + @Test + fun upsertReplacesADraftWithTheSameId() = runBlocking { + dao.upsert(draft("d1", subject = "First")) + + dao.upsert(draft("d1", subject = "Edited")) + + assertEquals("Edited", dao.getById("d1")?.subject) + assertEquals(1, dao.observeCount().first()) + } + + @Test + fun deleteRemovesTheDraft() = runBlocking { + dao.upsert(draft("d1")) + dao.upsert(draft("d2")) + + dao.delete("d1") + + assertEquals(listOf("d2"), dao.getAll().map { it.id }) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/FolderDaoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/FolderDaoTest.kt new file mode 100644 index 0000000..9be1989 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/FolderDaoTest.kt @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.dao.FolderDao +import org.libremail.data.local.entity.FolderEntity + +/** + * Real-SQLite behavior of [FolderDao] not already covered by [LibreMailDatabaseTest] (which pins + * `observeForAccount` ordering + `replaceForAccount`): the one-shot [FolderDao.getForAccountOnce] + * read, REPLACE-on-conflict for a re-listed folder, and the account scoping of a delete. + */ +@RunWith(AndroidJUnit4::class) +class FolderDaoTest { + + private lateinit var db: LibreMailDatabase + private lateinit var dao: FolderDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build() + dao = db.folderDao() + } + + @After + fun tearDown() = db.close() + + @Test + fun getForAccountOnceReturnsFoldersOrderedBySortOrder() = runBlocking { + dao.insertAll( + listOf( + FolderEntity("acct", "Archive", "Archive", "ARCHIVE", selectable = true, sortOrder = 2), + FolderEntity("acct", "INBOX", "INBOX", "INBOX", selectable = true, sortOrder = 0), + FolderEntity("acct", "Sent", "Sent", "SENT", selectable = true, sortOrder = 1), + ), + ) + + assertEquals(listOf("INBOX", "Sent", "Archive"), dao.getForAccountOnce("acct").map { it.fullName }) + } + + @Test + fun insertAllReplacesAFolderWithTheSamePrimaryKey() = runBlocking { + dao.insertAll( + listOf(FolderEntity("acct", "INBOX", "Old label", "INBOX", selectable = true, sortOrder = 0)), + ) + + // A re-list of INBOX (same accountId + fullName) replaces the cached row. + dao.insertAll( + listOf(FolderEntity("acct", "INBOX", "New label", "INBOX", selectable = true, sortOrder = 0)), + ) + + val folders = dao.getForAccountOnce("acct") + assertEquals(1, folders.size) + assertEquals("New label", folders.single().displayName) + } + + @Test + fun deleteForAccountRemovesOnlyThatAccountsFolders() = runBlocking { + dao.insertAll(listOf(FolderEntity("acct", "INBOX", "INBOX", "INBOX", selectable = true, sortOrder = 0))) + dao.insertAll(listOf(FolderEntity("acct2", "INBOX", "INBOX", "INBOX", selectable = true, sortOrder = 0))) + + dao.deleteForAccount("acct") + + assertTrue(dao.getForAccountOnce("acct").isEmpty()) + assertEquals(listOf("INBOX"), dao.getForAccountOnce("acct2").map { it.fullName }) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/MessageDaoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/MessageDaoTest.kt new file mode 100644 index 0000000..5a66ee6 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/MessageDaoTest.kt @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.paging.PagingSource +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.entity.MessageEntity +import org.libremail.data.local.entity.MessageSummary + +/** + * Real-SQLite behavior of the [MessageDao] query and mutation surface not already pinned by + * [MessageDaoRetentionTest] (the retention/backfill boundary probes) or [MessageDaoRoutingTest] (the + * body-less routing projection): the Paging 3 browse/search sources, the flag/body/header mutators, + * and the scoped deletes. Exercised against a real in-memory database so the generated SQL — and its + * `inInbox`/folder/account scoping and ordering — runs for real. + */ +@RunWith(AndroidJUnit4::class) +class MessageDaoTest { + + private lateinit var db: LibreMailDatabase + private lateinit var dao: MessageDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build() + dao = db.messageDao() + } + + @After + fun tearDown() = db.close() + + @Suppress("LongParameterList") + private fun message( + id: String, + accountId: String = "acct", + folder: String = "INBOX", + subject: String = "Hi", + sender: String = "Ada", + senderEmail: String = "ada@example.org", + snippet: String = "", + body: String = "", + timestampMillis: Long = 1_000L, + isRead: Boolean = false, + isStarred: Boolean = false, + inInbox: Boolean = true, + bodyFetched: Boolean = false, + isHtml: Boolean = false, + uid: Long = 0L, + ) = MessageEntity( + id = id, + accountId = accountId, + sender = sender, + senderEmail = senderEmail, + subject = subject, + snippet = snippet, + body = body, + isHtml = isHtml, + timestampMillis = timestampMillis, + isRead = isRead, + isStarred = isStarred, + folder = folder, + inInbox = inInbox, + bodyFetched = bodyFetched, + uid = uid, + ) + + /** Refreshes a [PagingSource] and returns the first loaded page's ids in order. */ + private suspend fun PagingSource.refreshIds(loadSize: Int = 20): List { + val result = load(PagingSource.LoadParams.Refresh(key = null, loadSize = loadSize, placeholdersEnabled = false)) + return (result as PagingSource.LoadResult.Page).data.map { it.id } + } + + @Test + fun pagingUnifiedFolderSummariesReturnsSyncedRowsNewestFirstAcrossAccounts() = runBlocking { + dao.insertNew( + listOf( + message("acct:INBOX:1", timestampMillis = 100), + message("acct:INBOX:2", timestampMillis = 300), + message("acct2:INBOX:3", accountId = "acct2", timestampMillis = 200), + message("acct:INBOX:search", timestampMillis = 999, inInbox = false), // search-only excluded + message("acct:Archive:1", folder = "Archive", timestampMillis = 500), // other folder excluded + ), + ) + + // Newest-first by timestamp, both accounts' INBOX rows, no search-only row, no other folder. + assertEquals( + listOf("acct:INBOX:2", "acct2:INBOX:3", "acct:INBOX:1"), + dao.pagingUnifiedFolderSummaries("INBOX").refreshIds(), + ) + } + + @Test + fun pagingFolderSummariesIsScopedToOneAccountAndFolder() = runBlocking { + dao.insertNew( + listOf( + message("acct:INBOX:1", timestampMillis = 100), + message("acct:INBOX:2", timestampMillis = 200), + message("acct2:INBOX:3", accountId = "acct2", timestampMillis = 300), // other account + message("acct:Archive:1", folder = "Archive", timestampMillis = 400), // other folder + message("acct:INBOX:s", timestampMillis = 999, inInbox = false), // search-only + ), + ) + + assertEquals( + listOf("acct:INBOX:2", "acct:INBOX:1"), + dao.pagingFolderSummaries("acct", "INBOX").refreshIds(), + ) + } + + @Test + fun pagingUnifiedFolderSearchSummariesMatchesEveryScannedColumnAndSurfacesSearchRows() = runBlocking { + dao.insertNew( + listOf( + message("bySubject", subject = "Quarterly report", timestampMillis = 100), + message("bySender", sender = "Reporter", subject = "x", timestampMillis = 200), + message("bySenderEmail", senderEmail = "report@x.org", subject = "x", timestampMillis = 300), + message("bySnippet", snippet = "see the report", subject = "x", timestampMillis = 400), + message("searchHit", subject = "report", timestampMillis = 500, inInbox = false), // surfaced + message("noMatch", subject = "unrelated", timestampMillis = 600), + message("otherFolder", subject = "report", folder = "Archive", timestampMillis = 700), + ), + ) + + // Unified search matches sender/senderEmail/subject/snippet, includes transient inInbox=0 hits, + // and is folder-scoped. Newest-first. + assertEquals( + listOf("searchHit", "bySnippet", "bySenderEmail", "bySender", "bySubject"), + dao.pagingUnifiedFolderSearchSummaries("INBOX", "%report%").refreshIds(), + ) + } + + @Test + fun pagingFolderSearchSummariesIsAccountScopedAndSurfacesSearchRows() = runBlocking { + dao.insertNew( + listOf( + message("mine", subject = "the report", timestampMillis = 100), + message("mineSearch", subject = "report", timestampMillis = 200, inInbox = false), + message("theirs", subject = "report", accountId = "acct2", timestampMillis = 300), + ), + ) + + assertEquals( + listOf("mineSearch", "mine"), + dao.pagingFolderSearchSummaries("acct", "INBOX", "%report%").refreshIds(), + ) + } + + @Test + fun getUnfetchedIdsReturnsOnlySyncedRowsMissingABody() = runBlocking { + dao.insertNew( + listOf( + message("unfetched", bodyFetched = false), + message("fetched", bodyFetched = true), + message("searchUnfetched", bodyFetched = false, inInbox = false), // not synced + message("otherFolder", folder = "Archive", bodyFetched = false), // different folder + ), + ) + + assertEquals(listOf("unfetched"), dao.getUnfetchedIds("acct", "INBOX")) + } + + @Test + fun insertNewIgnoresConflictsAndLeavesExistingRowsIntact() = runBlocking { + dao.insertNew(listOf(message("acct:1", subject = "Original", isRead = true))) + + // A re-insert of the same id (e.g. the next sync re-listing it) must NOT clobber the cached row. + dao.insertNew(listOf(message("acct:1", subject = "Replaced", isRead = false))) + + val row = dao.getById("acct:1") + assertEquals("Original", row?.subject) + assertEquals(true, row?.isRead) + } + + @Test + fun existingIdsReturnsOnlyThoseAlreadyStored() = runBlocking { + dao.insertNew(listOf(message("acct:1"), message("acct:2"))) + + assertEquals( + setOf("acct:1", "acct:2"), + dao.existingIds(listOf("acct:1", "acct:2", "acct:absent")).toSet(), + ) + } + + @Test + fun updateHeaderContentRefreshesDisplayFieldsAndUidOnly() = runBlocking { + dao.insertNew( + listOf( + message("acct:1", isRead = true, isStarred = true, inInbox = true, body = "cached", uid = 0), + ), + ) + + dao.updateHeaderContent( + id = "acct:1", + sender = "Charles", + senderEmail = "charles@example.org", + subject = "Refreshed", + timestampMillis = 5_000L, + uid = 42L, + ) + + val row = requireNotNull(dao.getById("acct:1")) + assertEquals("Charles", row.sender) + assertEquals("charles@example.org", row.senderEmail) + assertEquals("Refreshed", row.subject) + assertEquals(5_000L, row.timestampMillis) + assertEquals(42L, row.uid) + // Local flags, the cached body, and inbox membership are deliberately left untouched. + assertEquals(true, row.isRead) + assertEquals(true, row.isStarred) + assertEquals("cached", row.body) + assertEquals(true, row.inInbox) + } + + @Test + fun markSyncedPromotesSearchOnlyRowsIntoTheFolder() = runBlocking { + dao.insertNew( + listOf( + message("promote", inInbox = false), + message("leaveAlone", inInbox = false), + ), + ) + + dao.markSynced(listOf("promote")) + + assertEquals(listOf("promote"), dao.getSyncedIds("acct", "INBOX")) + assertFalse(dao.getById("leaveAlone")!!.inInbox) + } + + @Test + fun updateBodyStoresBodyHtmlSnippetAndMarksFetched() = runBlocking { + dao.insertNew(listOf(message("acct:1", bodyFetched = false))) + + dao.updateBody("acct:1", body = "

Hello

", isHtml = true, snippet = "Hello") + + val row = requireNotNull(dao.getById("acct:1")) + assertEquals("

Hello

", row.body) + assertTrue(row.isHtml) + assertEquals("Hello", row.snippet) + assertTrue(row.bodyFetched) + } + + @Test + fun setReadAndSetStarredToggleOnlyTheirFlag() = runBlocking { + dao.insertNew(listOf(message("acct:1", isRead = false, isStarred = false))) + + dao.setRead("acct:1", true) + dao.setStarred("acct:1", true) + + val row = requireNotNull(dao.getById("acct:1")) + assertTrue(row.isRead) + assertTrue(row.isStarred) + } + + @Test + fun deleteByIdsRemovesExactlyTheGivenRows() = runBlocking { + dao.insertNew(listOf(message("a"), message("b"), message("c"))) + + dao.deleteByIds(listOf("a", "c")) + + assertEquals(listOf("b"), dao.observeSummaries().first().map { it.id }) + } + + @Test + fun deleteByAccountRemovesEveryRowOfThatAccountOnly() = runBlocking { + dao.insertNew( + listOf( + message("acct:1"), + message("acct:Archive:1", folder = "Archive"), + message("acct2:1", accountId = "acct2"), + ), + ) + + dao.deleteByAccount("acct") + + assertEquals(listOf("acct2:1"), dao.observeSummaries().first().map { it.id }) + } + + @Test + fun deleteSyncedByAccountFolderSparesSearchRowsAndOtherFolders() = runBlocking { + dao.insertNew( + listOf( + message("synced", inInbox = true), + message("searchOnly", inInbox = false), + message("otherFolder", folder = "Archive"), + message("otherAccount", accountId = "acct2"), + ), + ) + + dao.deleteSyncedByAccountFolder("acct", "INBOX") + + assertNull("the synced INBOX row is deleted", dao.getById("synced")) + assertTrue("a transient search-only row is spared", dao.getById("searchOnly") != null) + assertTrue("another folder is untouched", dao.getById("otherFolder") != null) + assertTrue("another account is untouched", dao.getById("otherAccount") != null) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt index e2939a8..63e0796 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt @@ -73,6 +73,170 @@ class MigrationTest { db.close() } + /** v7 -> v8: pre-upgrade messages are filed under INBOX and the folders table appears. */ + @Test + fun migrate7To8_filesExistingMessagesUnderInboxAndAddsFoldersTable() { + helper.createDatabase(TEST_DB, 7).apply { + insertAccount() + 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', 'Hi', '', '', 0, 1000, 0, 0, 1, 1)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 8, true, MIGRATION_7_8) + + db.query("SELECT folder FROM messages WHERE id = 'acct:1'").use { c -> + assertTrue("the pre-upgrade message must survive", c.moveToFirst()) + assertEquals("7->8 files pre-upgrade rows under INBOX", "INBOX", c.getString(0)) + } + // The new folders table exists and accepts a row. + db.execSQL( + "INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) " + + "VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0)", + ) + assertEquals(1, db.count("folders")) + db.close() + } + + /** v8 -> v9: a default per-account settings row is backfilled for every existing account. */ + @Test + fun migrate8To9_backfillsADefaultSettingsRowPerAccount() { + helper.createDatabase(TEST_DB, 8).apply { + insertAccount() + close() + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 9, true, MIGRATION_8_9) + + db.query("SELECT signature, signatureEnabled, notificationsEnabled FROM account_settings").use { c -> + assertTrue("8->9 must backfill a settings row for the existing account", c.moveToFirst()) + assertEquals("", c.getString(0)) + assertEquals(1, c.getInt(1)) + assertEquals(1, c.getInt(2)) + assertFalse("exactly one settings row per account", c.moveToNext()) + } + db.close() + } + + /** v9 -> v10: `outbox`/`drafts` gain an empty `bccAddresses` and queued/saved rows survive. */ + @Test + fun migrate9To10_addsEmptyBccToOutboxAndDrafts() { + helper.createDatabase(TEST_DB, 9).apply { + execSQL( + "INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, subject, body, createdAt, " + + "lastError) VALUES ('out-1', 'acct', 'bob@example.org', '', 'Queued', 'Body', 3000, NULL)", + ) + execSQL( + "INSERT INTO drafts (id, accountId, toAddresses, ccAddresses, subject, body, updatedAt, " + + "attachments) VALUES ('draft-1', 'acct', 'bob@example.org', '', 'Draft', 'Text', 4000, '')", + ) + close() + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 10, true, MIGRATION_9_10) + + db.query("SELECT bccAddresses FROM outbox WHERE id = 'out-1'").use { c -> + assertTrue("the queued outbox row must survive", c.moveToFirst()) + assertEquals("existing outbox rows read an empty bcc", "", c.getString(0)) + } + db.query("SELECT bccAddresses FROM drafts WHERE id = 'draft-1'").use { c -> + assertTrue("the saved draft must survive", c.moveToFirst()) + assertEquals("existing draft rows read an empty bcc", "", c.getString(0)) + } + db.close() + } + + /** v10 -> v11: nullable `bodyHtml` appears and a legacy per-account signature is carried across. */ + @Test + fun migrate10To11_addsNullableBodyHtmlAndBackfillsTheDefaultSignature() { + helper.createDatabase(TEST_DB, 10).apply { + insertAccount() + execSQL( + "INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled) " + + "VALUES ('acct', 'Cheers,' || char(10) || 'Ada', 1, 1)", + ) + execSQL( + "INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, bccAddresses, subject, body, " + + "createdAt, lastError) VALUES ('out-1', 'acct', 'bob@example.org', '', '', 'Q', 'B', 1, NULL)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 11, true, MIGRATION_10_11) + + // The v9 signature becomes the account's default rich-text signature (newlines ->
). + db.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()) + } + // bodyHtml is added nullable and reads back null for a message composed before formatting. + db.query("SELECT bodyHtml FROM outbox WHERE id = 'out-1'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue("bodyHtml must default to null (plaintext-only)", c.isNull(0)) + } + db.close() + } + + /** v13 -> v14: snippets are re-derived per `isHtml`; only fetched-body rows are touched. */ + @Test + fun migrate13To14_reDerivesSnippetsRespectingIsHtmlAndSkipsUnfetchedRows() { + helper.createDatabase(TEST_DB, 13).apply { + insertV13Message( + id = "html", + isHtml = 1, + body = "

Hello world

", + snippet = "STALE", + bodyFetched = 1, + ) + insertV13Message( + id = "plain", + isHtml = 0, + body = "keep literal", + snippet = "STALE", + bodyFetched = 1, + ) + insertV13Message(id = "unfetched", isHtml = 0, body = "", snippet = "UNTOUCHED", bodyFetched = 0) + close() + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 14, true, MIGRATION_13_14) + + val htmlSnippet = snippetOf(db, "html") + assertTrue("HTML snippet keeps visible text", htmlSnippet.contains("Hello world")) + assertFalse("HTML snippet drops leaked