Merge main into fix-219-empty-state-flash

This commit is contained in:
Jason Ross
2026-07-03 20:49:15 -05:00
committed by GitHub
19 changed files with 705 additions and 17 deletions
@@ -221,11 +221,38 @@ class AccountDataMigratorTest {
}
}
@Test
fun copiedAccountsGetAStableAlphabeticalInitialSortOrder() = runBlocking<Unit> {
// Insert three accounts in non-alphabetical order so the assertion proves the initial order is
// by email (the pre-#111 listing) rather than the copy's insertion order (issue #164). sortOrder
// is a destination-only column the v14 cache never had, so it is assigned entirely by the copy.
helper.createDatabase(cacheName, 14).apply {
listOf("c" to "carol@example.org", "a" to "ada@example.org", "b" to "bob@example.org")
.forEach { (id, email) ->
execSQL(
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, " +
"imap_security, smtp_host, smtp_port, smtp_security) VALUES " +
"('$id', '$email', '$id', 'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', " +
"'smtp.example.org', 465, 'SSL_TLS')",
)
}
close()
}
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
openAccountsDb().apply {
// getAll orders by sortOrder; the copy ranked by email, so ada(0) < bob(1) < carol(2).
assertEquals(listOf("a", "b", "c"), accountDao().getAll().map { it.id })
close()
}
}
@Test
fun migratorDdlMatchesExportedAccountDatabaseSchema() {
val schema = JSONObject(
InstrumentationRegistry.getInstrumentation().context.assets
.open("org.libremail.data.local.AccountDatabase/1.json")
.open("org.libremail.data.local.AccountDatabase/2.json")
.bufferedReader().use { it.readText() },
).getJSONObject("database")
val entities = schema.getJSONArray("entities")
@@ -39,9 +39,9 @@ class AccountDatabaseTest {
@After
fun tearDown() = db.close()
private fun account(id: String = "acct") = AccountEntity(
private fun account(id: String = "acct", email: String = "$id@example.org") = AccountEntity(
id = id,
email = "a@example.org",
email = email,
displayName = "A",
authType = "PASSWORD_IMAP",
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
@@ -69,6 +69,34 @@ class AccountDatabaseTest {
assertNull("account_settings must cascade-delete with its account", db.accountSettingsDao().get("acct"))
}
@Test
fun insertAtEndAppendsAccountsInIncreasingSortOrder() = runBlocking<Unit> {
db.accountDao().insertAtEnd(account("a"))
db.accountDao().insertAtEnd(account("b"))
db.accountDao().insertAtEnd(account("c"))
// Each new account lands at the end: current max + 1, starting from 0 (issue #164).
assertEquals(0, db.accountDao().getById("a")?.sortOrder)
assertEquals(1, db.accountDao().getById("b")?.sortOrder)
assertEquals(2, db.accountDao().getById("c")?.sortOrder)
// observeAll now orders by sortOrder, i.e. the append (insertion) order.
assertEquals(listOf("a", "b", "c"), db.accountDao().observeAll().first().map { it.id })
}
@Test
fun reorderPersistsAndBothQueriesReflectTheNewOrder() = runBlocking<Unit> {
listOf("a", "b", "c").forEach { db.accountDao().insertAtEnd(account(it)) }
db.accountDao().reorder(listOf("c", "a", "b"))
assertEquals(listOf("c", "a", "b"), db.accountDao().observeAll().first().map { it.id })
assertEquals(listOf("c", "a", "b"), db.accountDao().getAll().map { it.id })
// sortOrder is renumbered to the new positions, so the order survives an app restart.
assertEquals(0, db.accountDao().getById("c")?.sortOrder)
assertEquals(1, db.accountDao().getById("a")?.sortOrder)
assertEquals(2, db.accountDao().getById("b")?.sortOrder)
}
@Test
fun signaturesCascadeWithTheirAccount() = runBlocking<Unit> {
db.accountDao().upsert(account())
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Replays [AccountDatabase]'s migrations against the schema JSONs exported to `app/schemas` (shipped
* to the test APK as assets). AccountDatabase is the non-auth account store split out of the cache in
* issue #111; like the cache database it registers no destructive fallback, so a drifted migration
* would crash upgrading users at first open — this test makes such drift fail in CI instead. Kept
* separate from [MigrationTest] because each database has its own version line and MigrationTestHelper
* binds to a single [androidx.room.RoomDatabase] class.
*/
@RunWith(AndroidJUnit4::class)
class AccountMigrationTest {
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AccountDatabase::class.java,
emptyList(),
FrameworkSQLiteOpenHelperFactory(),
)
/**
* v1 -> v2 (issue #164): `accounts.sortOrder` appears, and existing accounts are backfilled with a
* stable initial order matching the previous alphabetical (`ORDER BY email`) listing. The rows are
* inserted out of alphabetical order to prove the rank is by email, not insertion order.
*/
@Test
fun migrate1To2_addsSortOrderBackfilledByEmailRank() {
helper.createDatabase(TEST_DB, 1).apply {
insertAccount("c", "carol@example.org")
insertAccount("a", "ada@example.org")
insertAccount("b", "bob@example.org")
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, ACCOUNT_MIGRATION_1_2)
db.query("SELECT id, sortOrder FROM accounts ORDER BY sortOrder").use { c ->
assertTrue(c.moveToFirst())
assertEquals("ada ranks first alphabetically, so sortOrder 0", "a", c.getString(0))
assertEquals(0, c.getInt(1))
assertTrue(c.moveToNext())
assertEquals("b", c.getString(0))
assertEquals(1, c.getInt(1))
assertTrue(c.moveToNext())
assertEquals("c", c.getString(0))
assertEquals(2, c.getInt(1))
assertFalse("all three pre-upgrade accounts must survive, and nothing else", c.moveToNext())
}
db.close()
}
private fun SupportSQLiteDatabase.insertAccount(id: String, email: String) {
execSQL(
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
"smtp_host, smtp_port, smtp_security) VALUES ('$id', '$email', '$id', 'PASSWORD_IMAP', " +
"'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
)
}
private companion object {
const val TEST_DB = "account-migration-test.db"
}
}
@@ -62,6 +62,11 @@ class FakeAccountRepository(
accountsFlow.value = accountsFlow.value.filterNot { it.id == id }
}
override suspend fun reorderAccounts(orderedIds: List<String>) {
val byId = accountsFlow.value.associateBy { it.id }
accountsFlow.value = orderedIds.mapNotNull { byId[it] }
}
override suspend fun resetBackfillProgress(accountId: String?) = Unit
}