test(coverage): lane 3 — persistence, DAOs & migrations instrumented tests

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 18:32:36 -05:00
co-authored by Claude Opus 4.8
parent e600122cc0
commit 0d3b0e2a73
16 changed files with 1693 additions and 0 deletions
+3
View File
@@ -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)
}
@@ -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<Context>()
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"))
}
}
@@ -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<Context>()
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)
}
}
@@ -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<Context>()
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 })
}
}
@@ -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<Context>()
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"))
}
}
@@ -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<Context>()
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"))
}
}
@@ -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<Unit> {
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<Unit> {
// 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<Unit> {
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<Unit> {
// 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()
@@ -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<Context>()
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<DatabaseKeyStore>()
private val settingsRepository = mockk<SettingsRepository>()
private val migrator = mockk<AccountDataMigrator>()
private val context = mockk<Context>()
@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<Unit> {
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<Unit> {
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<Unit> {
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()
}
}
}
@@ -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<Context>()
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<SupportSQLiteDatabase>(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)
}
}
@@ -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<Context>()
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 })
}
}
@@ -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<Context>()
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 })
}
}
@@ -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<Context>()
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<Int, MessageSummary>.refreshIds(loadSize: Int = 20): List<String> {
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 = "<p>Hello</p>", isHtml = true, snippet = "Hello")
val row = requireNotNull(dao.getById("acct:1"))
assertEquals("<p>Hello</p>", 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)
}
}
@@ -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 -> <br>).
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,<br>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 = "<style>p{color:red}</style><p>Hello world</p>",
snippet = "STALE",
bodyFetched = 1,
)
insertV13Message(
id = "plain",
isHtml = 0,
body = "keep <not a tag> 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 <style> content", htmlSnippet.contains("color"))
assertFalse("the stale snippet is replaced", htmlSnippet.contains("STALE"))
// A plain-text body keeps its literal angle brackets (no markup handling).
assertTrue("plain snippet keeps literal markup", snippetOf(db, "plain").contains("<not a tag>"))
// Rows whose body was never fetched keep their existing snippet.
assertEquals("UNTOUCHED", snippetOf(db, "unfetched"))
db.close()
}
private fun snippetOf(db: SupportSQLiteDatabase, id: String): String =
db.query("SELECT snippet FROM messages WHERE id = ?", arrayOf<Any>(id)).use { c ->
assertTrue("row $id must exist", c.moveToFirst())
c.getString(0)
}
private fun SupportSQLiteDatabase.insertV13Message(
id: String,
isHtml: Int,
body: String,
snippet: String,
bodyFetched: Int,
) = execSQL(
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) " +
"VALUES (?, 'acct', 'Ada', 'ada@example.org', 'Hi', ?, ?, ?, 1000, 0, 0, 'INBOX', 1, ?, 1)",
arrayOf<Any>(id, snippet, body, isHtml, bodyFetched),
)
/** v14 -> v15 (issue #66): `folders.hierarchyDelimiter` appears defaulting to NULL; data survives. */
@Test
fun migrate14To15_addsNullHierarchyDelimiterToFolders() {
@@ -0,0 +1,97 @@
// 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.OutboxDao
import org.libremail.data.local.entity.OutboxEntity
/**
* Real-SQLite behavior of [OutboxDao] — the queue the send worker drains: FIFO ordering by
* `createdAt`, point lookups, the live list/count observers, the last-error mutator, and row
* deletion on successful send.
*/
@RunWith(AndroidJUnit4::class)
class OutboxDaoTest {
private lateinit var db: LibreMailDatabase
private lateinit var dao: OutboxDao
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
dao = db.outboxDao()
}
@After
fun tearDown() = db.close()
private fun outbox(id: String, createdAt: Long = 1_000L) = OutboxEntity(
id = id,
accountId = "acct",
toAddresses = "bob@example.org",
ccAddresses = "",
subject = "Subject $id",
body = "Body",
createdAt = createdAt,
)
@Test
fun getAllReturnsQueuedMessagesOldestFirst() = runBlocking {
dao.insert(outbox("late", createdAt = 300))
dao.insert(outbox("early", createdAt = 100))
dao.insert(outbox("middle", createdAt = 200))
assertEquals(listOf("early", "middle", "late"), dao.getAll().map { it.id })
}
@Test
fun getByIdReturnsTheRowOrNull() = runBlocking {
dao.insert(outbox("out-1"))
assertEquals("Subject out-1", dao.getById("out-1")?.subject)
assertNull(dao.getById("absent"))
}
@Test
fun observeAllAndObserveCountReflectTheQueue() = runBlocking {
dao.insert(outbox("out-1", createdAt = 100))
dao.insert(outbox("out-2", createdAt = 200))
assertEquals(listOf("out-1", "out-2"), dao.observeAll().first().map { it.id })
assertEquals(2, dao.observeCount().first())
}
@Test
fun setErrorRecordsAndThenClearsTheLastError() = runBlocking {
dao.insert(outbox("out-1"))
dao.setError("out-1", "SMTP 550")
assertEquals("SMTP 550", dao.getById("out-1")?.lastError)
dao.setError("out-1", null)
assertNull("a successful retry clears the error", dao.getById("out-1")?.lastError)
}
@Test
fun deleteRemovesTheSentRow() = runBlocking {
dao.insert(outbox("out-1"))
dao.insert(outbox("out-2"))
dao.delete("out-1")
assertEquals(listOf("out-2"), dao.getAll().map { it.id })
assertEquals(1, dao.observeCount().first())
}
}
@@ -0,0 +1,153 @@
// 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.SignatureDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.local.entity.SignatureEntity
/**
* Real-SQLite behavior of [SignatureDao] in [AccountDatabase]: the default-first / name-ordered
* observation, the default/first/count reads, and the "exactly one default per account" transaction
* ([SignatureDao.setDefault]). A parent account row is inserted first because signatures foreign-key
* to `accounts`.
*/
@RunWith(AndroidJUnit4::class)
class SignatureDaoTest {
private lateinit var db: AccountDatabase
private lateinit var dao: SignatureDao
private lateinit var accountDao: AccountDao
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
dao = db.signatureDao()
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"),
),
)
private fun signature(id: String, name: String, isDefault: Boolean = false, accountId: String = "acct") =
SignatureEntity(
id = id,
accountId = accountId,
name = name,
contentHtml = "<p>$name</p>",
isDefault = isDefault,
)
@Test
fun observeForAccountOrdersDefaultFirstThenByNameCaseInsensitive() = runBlocking {
insertAccount()
dao.upsert(signature("s-zeta", "Zeta", isDefault = true))
dao.upsert(signature("s-alpha", "alpha"))
dao.upsert(signature("s-beta", "Beta"))
// isDefault DESC puts the default first; the rest sort by name COLLATE NOCASE (alpha < Beta).
assertEquals(
listOf("Zeta", "alpha", "Beta"),
dao.observeForAccount("acct").first().map { it.name },
)
}
@Test
fun getByIdGetDefaultFirstForAccountAndCountReadTheExpectedRows() = runBlocking {
insertAccount()
dao.upsert(signature("s-work", "Work", isDefault = true))
dao.upsert(signature("s-personal", "aPersonal"))
assertEquals("Work", dao.getById("s-work")?.name)
assertNull(dao.getById("absent"))
assertEquals("Work", dao.getDefault("acct")?.name)
// firstForAccount ignores isDefault and takes the name-first row (aPersonal < Work).
assertEquals("aPersonal", dao.firstForAccount("acct")?.name)
assertEquals(2, dao.countForAccount("acct"))
}
@Test
fun getDefaultIsNullWhenNoSignatureIsMarkedDefault() = runBlocking {
insertAccount()
dao.upsert(signature("s-1", "One"))
assertNull(dao.getDefault("acct"))
assertEquals(0, dao.countForAccount("absent"))
}
@Test
fun upsertReplacesASignatureAndDeleteRemovesIt() = runBlocking {
insertAccount()
dao.upsert(signature("s-1", "Original"))
dao.upsert(signature("s-1", "Edited"))
assertEquals("Edited", dao.getById("s-1")?.name)
dao.delete("s-1")
assertNull(dao.getById("s-1"))
}
@Test
fun clearDefaultAndMarkDefaultToggleTheFlag() = runBlocking {
insertAccount()
dao.upsert(signature("s-1", "One", isDefault = true))
dao.clearDefault("acct")
assertNull("clearDefault drops the account's default", dao.getDefault("acct"))
dao.markDefault("s-1")
assertEquals("s-1", dao.getDefault("acct")?.id)
}
@Test
fun setDefaultMakesExactlyOneSignatureTheAccountsDefault() = runBlocking {
insertAccount()
dao.upsert(signature("s-1", "One", isDefault = true))
dao.upsert(signature("s-2", "Two"))
dao.setDefault("acct", "s-2")
// The transaction clears every other default first, so only s-2 remains default.
assertEquals("s-2", dao.getDefault("acct")?.id)
assertEquals(false, dao.getById("s-1")?.isDefault)
assertEquals(true, dao.getById("s-2")?.isDefault)
}
@Test
fun setDefaultIsScopedToTheAccount() = runBlocking {
insertAccount("acct")
insertAccount("acct2")
dao.upsert(signature("a1", "A1", isDefault = true, accountId = "acct"))
dao.upsert(signature("b1", "B1", isDefault = true, accountId = "acct2"))
dao.setDefault("acct", "a1")
// clearDefault in setDefault only touches the target account; acct2's default is untouched.
assertEquals("b1", dao.getDefault("acct2")?.id)
}
}
+2
View File
@@ -110,6 +110,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
# mockk's Android artifact for instrumented tests (dexmaker/byte-buddy-android backed).
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }