test(coverage): lane 3 — persistence, DAOs & migrations to >=95% #270
@@ -339,4 +339,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", 19, version)
|
||||
}
|
||||
|
||||
private fun openPlaintext(): LibreMailDatabase =
|
||||
Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build()
|
||||
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
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>()
|
||||
|
||||
// A real ContextWrapper, NOT a mockk<Context>: mocking android.content.Context makes MockK walk the
|
||||
// whole framework class with kotlin-reflect (isKotlinInline), which trips an ART parameter-annotation
|
||||
// length mismatch and throws ArrayIndexOutOfBoundsException on API 31/32 (it passes on API 29). The
|
||||
// wrapper routes the provisioner's cache lookup to the test DB and delegates everything else.
|
||||
private val context: Context = object : ContextWrapper(appContext) {
|
||||
override fun getDatabasePath(name: String): File =
|
||||
if (name == DatabaseFiles.NAME) dbFile else super.getDatabasePath(name)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
clean()
|
||||
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,317 @@
|
||||
// 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,
|
||||
// Mirror production's Unicode-casefold population (issue #232): Mappers.toEntity + MessageDao's
|
||||
// updateHeaderContent/updateBody write `lowercase()` copies of the searchable fields, and the
|
||||
// *SearchSummaries queries match against these `*Fold` columns — so fixtures must set them too.
|
||||
senderFold = sender.lowercase(),
|
||||
senderEmailFold = senderEmail.lowercase(),
|
||||
subjectFold = subject.lowercase(),
|
||||
snippetFold = snippet.lowercase(),
|
||||
)
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user