Merge branch 'main' into perf-unified-inbox-paging
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
|
||||
/**
|
||||
* The one-time move performed by [AccountDataMigrator] (issue #111): copying accounts / credentials /
|
||||
* per-account settings / signatures out of the cache database into the plaintext [AccountDatabase].
|
||||
*
|
||||
* Exercises the [AccountDataMigrator.copyAccountTables] core directly (the full [AccountDataMigrator]
|
||||
* also resolves the passphrase and flips the done-flag, which need the real DataStore/Keystore). A v14
|
||||
* cache is built with [MigrationTestHelper] from the exported schema, so the copy runs against exactly
|
||||
* the on-disk shape an upgrading user has.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountDataMigratorTest {
|
||||
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
LibreMailDatabase::class.java,
|
||||
emptyList(),
|
||||
FrameworkSQLiteOpenHelperFactory(),
|
||||
)
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val cacheName = "acct-migrator-cache-test.db"
|
||||
private val accountsName = "acct-migrator-accounts-test.db"
|
||||
private val cacheFile get() = context.getDatabasePath(cacheName)
|
||||
private val accountsFile get() = context.getDatabasePath(accountsName)
|
||||
|
||||
// 64 hex chars == a 32-byte SQLCipher passphrase.
|
||||
private val passphrase = "0123456789abcdef".repeat(4)
|
||||
|
||||
@Before
|
||||
@After
|
||||
fun clean() {
|
||||
listOf(cacheName, accountsName).forEach { name ->
|
||||
context.deleteDatabase(name)
|
||||
context.getDatabasePath(name).parentFile
|
||||
?.listFiles { f -> f.name.startsWith(name) }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a v14 cache holding one fully-populated account plus a mail row. */
|
||||
private fun seedVersion14Cache() {
|
||||
helper.createDatabase(cacheName, 14).apply {
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
|
||||
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
|
||||
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
|
||||
)
|
||||
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
|
||||
execSQL(
|
||||
"INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled, " +
|
||||
"retentionCount, retentionMonths) VALUES ('acct', 'Cheers', 1, 0, NULL, 6)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO signatures (id, accountId, name, contentHtml, isDefault) " +
|
||||
"VALUES ('sig-1', 'acct', 'Work', '<p>Regards</p>', 1)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('acct:INBOX:1', 'acct', 'Ada', 'a@x', 'Hi', '', '', 0, 1, 0, 0, 'INBOX', 1, 0, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAccountsDb(): AccountDatabase =
|
||||
Room.databaseBuilder(context, AccountDatabase::class.java, accountsName).build()
|
||||
|
||||
@Test
|
||||
fun movesEveryAccountTableOutOfAPlaintextCache() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
// First open lets Room stamp its identity onto the migrator-created file; reopen so a real
|
||||
// session's reads run against a fully Room-owned database.
|
||||
openAccountsDb().apply {
|
||||
assertEquals("Ada", accountDao().getById("acct")?.displayName)
|
||||
close()
|
||||
}
|
||||
openAccountsDb().apply {
|
||||
val account = accountDao().getById("acct")
|
||||
assertEquals("ada@example.org", account?.email)
|
||||
assertEquals(993, account?.imap?.port)
|
||||
assertEquals("smtp.example.org", account?.smtp?.host)
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
val settings = accountSettingsDao().get("acct")
|
||||
// Seeded signatureEnabled = 1, notificationsEnabled = 0: both booleans must round-trip.
|
||||
assertEquals(true, settings?.signatureEnabled)
|
||||
assertEquals(false, settings?.notificationsEnabled)
|
||||
assertEquals(6, settings?.retentionMonths)
|
||||
assertNull(settings?.retentionCount)
|
||||
val signatures = signatureDao().observeForAccount("acct").first()
|
||||
assertEquals(listOf("Work"), signatures.map { it.name })
|
||||
assertTrue("the default flag must round-trip", signatures.single().isDefault)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movesAccountsOutOfAnEncryptedCache() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
// Turn the cache into the SQLCipher form an app-lock + encrypted-cache user has on disk.
|
||||
DatabaseEncryption.ensureEncrypted(cacheFile, passphrase)
|
||||
assertTrue("precondition: the source cache is encrypted", DatabaseEncryption.isEncrypted(cacheFile))
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = passphrase, accountsFile = accountsFile)
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertNotNull(accountDao().getById("acct"))
|
||||
close()
|
||||
}
|
||||
openAccountsDb().apply {
|
||||
assertEquals("ada@example.org", accountDao().getById("acct")?.email)
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reRunningTheCopyIsIdempotentAndKeepsLaterEdits() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
// Simulate the user editing an account AFTER the migration.
|
||||
openAccountsDb().apply {
|
||||
val edited = accountDao().getById("acct")!!.copy(displayName = "Ada Lovelace")
|
||||
accountDao().upsert(edited)
|
||||
close()
|
||||
}
|
||||
|
||||
// A re-run (e.g. after a mid-startup crash before the done-flag was set) must not clobber it.
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertEquals(1, accountDao().getAll().size)
|
||||
assertEquals(
|
||||
"INSERT OR IGNORE must not overwrite the post-migration edit",
|
||||
"Ada Lovelace",
|
||||
accountDao().getById("acct")?.displayName,
|
||||
)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountsAndCredentialsSurviveACacheWipe() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
// The "clear + re-sync" recovery wipes only the cache file; AccountDatabase is a separate file.
|
||||
context.deleteDatabase(cacheName)
|
||||
assertTrue("precondition: the cache file is gone", !cacheFile.exists())
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertNotNull("the account must outlive a cache wipe (issue #111)", accountDao().getById("acct"))
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
// And it is still usable: a fresh credential can be written with no cache present.
|
||||
credentialDao().upsert(CredentialEntity("acct", "rotated"))
|
||||
assertEquals("rotated", credentialDao().getById("acct")?.encryptedSecret)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiesFromACacheOlderThanTheCurrentSchema() = runBlocking<Unit> {
|
||||
// A cache last written at v12 — before account_settings gained retentionCount/retentionMonths
|
||||
// (v13). The copy must not choke on the columns the destination has but the source lacks
|
||||
// (a device upgrade from an old install crashed the migrator here).
|
||||
helper.createDatabase(cacheName, 12).apply {
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
|
||||
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
|
||||
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
|
||||
)
|
||||
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
|
||||
execSQL(
|
||||
"INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled) " +
|
||||
"VALUES ('acct', 'Sig', 0, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
openAccountsDb().apply {
|
||||
assertEquals("ada@example.org", accountDao().getById("acct")?.email)
|
||||
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
|
||||
val settings = accountSettingsDao().get("acct")
|
||||
assertEquals(false, settings?.signatureEnabled)
|
||||
assertEquals(true, settings?.notificationsEnabled)
|
||||
// Columns the v12 source lacked come across as the destination's defaults (null).
|
||||
assertNull("retentionCount absent from a v12 cache must default to null", settings?.retentionCount)
|
||||
assertNull(settings?.retentionMonths)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migratorDdlMatchesExportedAccountDatabaseSchema() {
|
||||
val schema = JSONObject(
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("org.libremail.data.local.AccountDatabase/1.json")
|
||||
.bufferedReader().use { it.readText() },
|
||||
).getJSONObject("database")
|
||||
val entities = schema.getJSONArray("entities")
|
||||
|
||||
var checkedIndex = false
|
||||
for (i in 0 until entities.length()) {
|
||||
val entity = entities.getJSONObject(i)
|
||||
val table = entity.getString("tableName")
|
||||
val expectedCreate = entity.getString("createSql").replace("\${TABLE_NAME}", table)
|
||||
assertEquals(
|
||||
"AccountDataMigrator DDL for `$table` must match the exported AccountDatabase schema",
|
||||
expectedCreate,
|
||||
AccountDataMigrator.CREATE_TABLE_SQL[table],
|
||||
)
|
||||
if (entity.has("indices")) {
|
||||
val indices = entity.getJSONArray("indices")
|
||||
for (j in 0 until indices.length()) {
|
||||
val index = indices.getJSONObject(j)
|
||||
if (index.getString("name") == "index_signatures_accountId") {
|
||||
assertEquals(
|
||||
"AccountDataMigrator signatures index must match the exported schema",
|
||||
index.getString("createSql").replace("\${TABLE_NAME}", table),
|
||||
AccountDataMigrator.SIGNATURES_INDEX_SQL,
|
||||
)
|
||||
checkedIndex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(
|
||||
"every migrator table DDL must correspond to an exported entity",
|
||||
AccountDataMigrator.CREATE_TABLE_SQL.keys,
|
||||
(0 until entities.length()).map { entities.getJSONObject(it).getString("tableName") }.toSet(),
|
||||
)
|
||||
assertTrue("the signatures index must be present in the exported schema", checkedIndex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
/**
|
||||
* Behavior of the non-auth [AccountDatabase] that holds accounts, credentials, per-account settings
|
||||
* and signatures after they were moved out of the cache database (issue #111). Confirms the tables'
|
||||
* foreign keys still cascade from the account, now that they live together in this database.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountDatabaseTest {
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun account(id: String = "acct") = AccountEntity(
|
||||
id = id,
|
||||
email = "a@example.org",
|
||||
displayName = "A",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun credentialRoundTripsAndIsIndependentOfTheAccountRow() = runBlocking<Unit> {
|
||||
db.accountDao().upsert(account())
|
||||
db.credentialDao().upsert(CredentialEntity("acct", "sealed-secret"))
|
||||
|
||||
assertEquals("sealed-secret", db.credentialDao().getById("acct")?.encryptedSecret)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountSettingsRoundTripAndCascadeWithTheirAccount() = runBlocking<Unit> {
|
||||
db.accountDao().upsert(account())
|
||||
db.accountSettingsDao().upsert(
|
||||
AccountSettingsEntity("acct", signature = "Hi", signatureEnabled = false, notificationsEnabled = false),
|
||||
)
|
||||
assertEquals("Hi", db.accountSettingsDao().get("acct")?.signature)
|
||||
|
||||
db.accountDao().deleteById("acct")
|
||||
|
||||
assertNull("account_settings must cascade-delete with its account", db.accountSettingsDao().get("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signaturesCascadeWithTheirAccount() = runBlocking<Unit> {
|
||||
db.accountDao().upsert(account())
|
||||
db.signatureDao().upsert(SignatureEntity("sig-1", "acct", "Work", "<p>Regards</p>", isDefault = true))
|
||||
assertEquals(1, db.signatureDao().observeForAccount("acct").first().size)
|
||||
|
||||
db.accountDao().deleteById("acct")
|
||||
|
||||
assertTrue(
|
||||
"signatures must cascade-delete with their account",
|
||||
db.signatureDao().observeForAccount("acct").first().isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,22 +9,21 @@ 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.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
|
||||
/**
|
||||
* Schema-behavior tests on a fresh in-memory database at the current version. The migration DDL
|
||||
* itself is exercised by [MigrationTest], which replays the schema chain exported to app/schemas.
|
||||
* (Migrations from before v7 predate schema export, so they can't be replayed there.)
|
||||
*
|
||||
* Account/credential/settings/signature behavior moved to [AccountDatabaseTest] with those tables
|
||||
* (issue #111).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LibreMailDatabaseTest {
|
||||
@@ -97,30 +96,6 @@ class LibreMailDatabaseTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountSettingsRoundTripAndCascadeWithTheirAccount() = runBlocking {
|
||||
val accountDao = db.accountDao()
|
||||
val settingsDao = db.accountSettingsDao()
|
||||
accountDao.upsert(
|
||||
AccountEntity(
|
||||
id = "acct",
|
||||
email = "a@example.org",
|
||||
displayName = "A",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
),
|
||||
)
|
||||
settingsDao.upsert(
|
||||
AccountSettingsEntity("acct", signature = "Hi", signatureEnabled = false, notificationsEnabled = false),
|
||||
)
|
||||
assertEquals("Hi", settingsDao.get("acct")?.signature)
|
||||
|
||||
accountDao.deleteById("acct")
|
||||
|
||||
assertNull("account_settings must cascade-delete with its account", settingsDao.get("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchRowsAreNotInboxAndAreCleared() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
|
||||
@@ -133,6 +133,9 @@ class MigrationTest {
|
||||
open?.close()
|
||||
val stepDb = helper.runMigrationsAndValidate(TEST_DB, migration.endVersion, true, migration)
|
||||
stepDb.writeMidChainData()
|
||||
// v16 moves the account tables out to AccountDatabase and drops them, so assert their rows
|
||||
// and backfills reached v15 intact — just before the move (issue #111).
|
||||
if (stepDb.version == 15) stepDb.assertAccountDataPresentAtV15()
|
||||
open = stepDb
|
||||
}
|
||||
val db = checkNotNull(open) { "no migration starts at v$OLDEST_EXPORTED_SCHEMA" }
|
||||
@@ -140,6 +143,29 @@ class MigrationTest {
|
||||
assertEquals("the chain must end at the newest exported schema", latestExportedSchemaVersion(), db.version)
|
||||
db.assertVersion7CacheSurvived()
|
||||
db.assertMigrationBackfillsApplied()
|
||||
db.assertAccountTablesDroppedAtV16()
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v15 -> v16 (issue #111): the moved account tables are dropped and the mail cache is untouched. */
|
||||
@Test
|
||||
fun migrate15To16_dropsMovedAccountTablesAndKeepsCache() {
|
||||
helper.createDatabase(TEST_DB, 15).apply {
|
||||
insertAccount()
|
||||
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed')")
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " +
|
||||
"'INBOX', 1, 0, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 16, true, MIGRATION_15_16)
|
||||
|
||||
db.assertAccountTablesDroppedAtV16()
|
||||
assertEquals("the mail cache must be untouched by 15->16", 1, db.count("messages"))
|
||||
db.close()
|
||||
}
|
||||
|
||||
@@ -203,9 +229,8 @@ class MigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** Every row cached at v7 must still be present and correct at the end of the chain. */
|
||||
/** Every mail-cache row cached at v7 must survive to v16 (account tables are checked separately). */
|
||||
private fun SupportSQLiteDatabase.assertVersion7CacheSurvived() {
|
||||
assertEquals(1, count("accounts"))
|
||||
assertEquals(2, count("messages"))
|
||||
assertEquals(1, count("outbox"))
|
||||
assertEquals(1, count("drafts"))
|
||||
@@ -215,24 +240,14 @@ class MigrationTest {
|
||||
assertEquals("Analytical engines", c.getString(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
}
|
||||
query("SELECT encryptedSecret FROM credentials WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("stored credentials must never be dropped by a migration", c.moveToFirst())
|
||||
assertEquals("sealed-secret", c.getString(0))
|
||||
}
|
||||
query("SELECT filename FROM attachments WHERE messageId = 'acct:1'").use { c ->
|
||||
assertTrue("attachment rows must survive the 6->7 style table rebuilds", c.moveToFirst())
|
||||
assertEquals("notes.pdf", c.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
/** Columns and rows created by the migrations themselves must hold their documented defaults. */
|
||||
/** Cache-table columns/rows the migrations backfill must hold their documented defaults at v16. */
|
||||
private fun SupportSQLiteDatabase.assertMigrationBackfillsApplied() {
|
||||
// 8->9 backfills one default settings row per existing account.
|
||||
query("SELECT signatureEnabled, notificationsEnabled FROM account_settings").use { c ->
|
||||
assertTrue("8->9 must backfill a settings row for the v7 account", c.moveToFirst())
|
||||
assertEquals(1, c.getInt(0))
|
||||
assertEquals(1, c.getInt(1))
|
||||
}
|
||||
// 9->10 adds bcc columns defaulting to ''; 10->11 adds nullable bodyHtml.
|
||||
query("SELECT bccAddresses, bodyHtml FROM outbox WHERE id = 'out-1'").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
@@ -244,14 +259,6 @@ class MigrationTest {
|
||||
assertEquals("", c.getString(0))
|
||||
assertTrue(c.isNull(1))
|
||||
}
|
||||
// 10->11 turns the signature written at v9 into that account's default rich-text signature.
|
||||
query("SELECT name, contentHtml, isDefault FROM signatures WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("10->11 must backfill the legacy per-account signature", c.moveToFirst())
|
||||
assertEquals("Signature", c.getString(0))
|
||||
assertEquals("Cheers,<br>Ada", c.getString(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
assertFalse("exactly one signature row must be backfilled", c.moveToNext())
|
||||
}
|
||||
// 11->12 stamps the folder cached at v8 as not special-use.
|
||||
query("SELECT specialUse FROM folders WHERE fullName = 'INBOX'").use { c ->
|
||||
assertTrue("folder cached at v8 must survive to the newest version", c.moveToFirst())
|
||||
@@ -265,6 +272,42 @@ class MigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account tables' rows + migration backfills must be intact at v15, just before 15->16 moves
|
||||
* them to [AccountDatabase] and drops them (issue #111). AccountDataMigrator's own copy is
|
||||
* exercised in `AccountDataMigratorTest`; here we only assert the source rows reach the move point.
|
||||
*/
|
||||
private fun SupportSQLiteDatabase.assertAccountDataPresentAtV15() {
|
||||
assertEquals(1, count("accounts"))
|
||||
query("SELECT encryptedSecret FROM credentials WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("stored credentials must reach v15 before the move", c.moveToFirst())
|
||||
assertEquals("sealed-secret", c.getString(0))
|
||||
}
|
||||
// 8->9 backfills one default settings row per existing account.
|
||||
query("SELECT signatureEnabled, notificationsEnabled FROM account_settings").use { c ->
|
||||
assertTrue("8->9 must backfill a settings row for the v7 account", c.moveToFirst())
|
||||
assertEquals(1, c.getInt(0))
|
||||
assertEquals(1, c.getInt(1))
|
||||
}
|
||||
// 10->11 turns the signature written at v9 into that account's default rich-text signature.
|
||||
query("SELECT name, contentHtml, isDefault FROM signatures WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("10->11 must backfill the legacy per-account signature", c.moveToFirst())
|
||||
assertEquals("Signature", c.getString(0))
|
||||
assertEquals("Cheers,<br>Ada", c.getString(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
assertFalse("exactly one signature row must be backfilled", c.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
/** 15->16 drops the account tables from the cache (AccountDataMigrator copies them out first). */
|
||||
private fun SupportSQLiteDatabase.assertAccountTablesDroppedAtV16() {
|
||||
listOf("accounts", "credentials", "account_settings", "signatures").forEach { table ->
|
||||
query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '$table'").use { c ->
|
||||
assertFalse("15->16 must drop `$table` from the cache database", c.moveToFirst())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.count(table: String): Int = query("SELECT COUNT(*) FROM $table").use { c ->
|
||||
c.moveToFirst()
|
||||
c.getInt(0)
|
||||
|
||||
@@ -26,7 +26,7 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.contacts.ContactsRepository
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.domain.model.Account
|
||||
@@ -56,7 +56,7 @@ class ComposeScreenTest {
|
||||
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
|
||||
)
|
||||
|
||||
private var db: LibreMailDatabase? = null
|
||||
private var db: AccountDatabase? = null
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
@@ -79,7 +79,7 @@ class ComposeScreenTest {
|
||||
// Build the view model once and capture it, so recomposition doesn't recreate it.
|
||||
private fun setContent(mailRepository: FakeMailRepository = FakeMailRepository(), onBack: () -> Unit = {}) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
val database = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build().also { db = it }
|
||||
val database = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build().also { db = it }
|
||||
val viewModel = ComposeViewModel(
|
||||
savedStateHandle = SavedStateHandle(),
|
||||
mailRepository = mailRepository,
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
@@ -59,7 +59,7 @@ class AccountSettingsScreenTest {
|
||||
// stateIn/WhileSubscribed) keeps querying after the test body, so closing the in-memory DB out
|
||||
// from under it races and crashes ("connection pool has been closed"). The DB is reclaimed with
|
||||
// the test process.
|
||||
val db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
val db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
val repository = AccountSettingsRepository(db.accountSettingsDao())
|
||||
runBlocking {
|
||||
db.accountDao().upsert(account.toEntity()) // FK parent for the account_settings row
|
||||
|
||||
Reference in New Issue
Block a user