diff options
Diffstat (limited to 'app/storage/src/test/java/com')
30 files changed, 5165 insertions, 0 deletions
diff --git a/app/storage/src/test/java/com/fsck/k9/preferences/StorageEditorTest.kt b/app/storage/src/test/java/com/fsck/k9/preferences/StorageEditorTest.kt new file mode 100644 index 0000000..df3cc70 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/preferences/StorageEditorTest.kt @@ -0,0 +1,207 @@ +package com.fsck.k9.preferences + +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback +import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations +import com.fsck.k9.storage.K9RobolectricTest +import org.junit.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions + +class StorageEditorTest : K9RobolectricTest() { + private val storage: Storage = Storage(mapOf("storage-key" to "storage-value")) + private val storageUpdater = TestStorageUpdater(storage) + private val storagePersister = mock<K9StoragePersister>() + private val storagePersisterOps = mock<StoragePersistOperations>() + private val editor = K9StorageEditor(storageUpdater, storagePersister) + + private val workingMap = mutableMapOf<String, String>() + + private val newValues: Map<String, String> + get() = storageUpdater.newStorage!!.all + + @Test + fun commit_exception() { + stubbing(storagePersister) { + on { doInTransaction(any()) } doThrow RuntimeException() + } + + val success = editor.commit() + + assertThat(success).isFalse() + } + + @Test + fun commit_trivial() { + prepareStoragePersisterMock() + + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEqualTo(storage.all) + + verifyNoMoreInteractions(storagePersisterOps) + } + + @Test + fun putBoolean() { + prepareStoragePersisterMock() + + editor.putBoolean("x", true) + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "true")) + verify(storagePersisterOps).put("x", "true") + verifyNoMoreInteractions(storagePersisterOps) + } + + @Test + fun putInt() { + prepareStoragePersisterMock() + + editor.putInt("x", 123) + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "123")) + verify(storagePersisterOps).put("x", "123") + verifyNoMoreInteractions(storagePersisterOps) + } + + @Test + fun putLong() { + prepareStoragePersisterMock() + + editor.putLong("x", 1234) + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "1234")) + verify(storagePersisterOps).put("x", "1234") + verifyNoMoreInteractions(storagePersisterOps) + } + + @Test + fun putString() { + prepareStoragePersisterMock() + + editor.putString("x", "y") + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value", "x" to "y")) + verify(storagePersisterOps).put("x", "y") + verifyNoMoreInteractions(storagePersisterOps) + } + + @Test + fun putString_duplicateSame() { + prepareStoragePersisterMock() + + editor.putString("storage-key", "storage-value") + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value")) + verifyNoMoreInteractions(storagePersisterOps) + } + + @Test + fun putString_duplicateOther() { + prepareStoragePersisterMock() + + editor.putString("storage-key", "other-value") + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEqualTo(mapOf("storage-key" to "other-value")) + verify(storagePersisterOps).put("storage-key", "other-value") + verifyNoMoreInteractions(storagePersisterOps) + } + + @Test + fun putString_removedDuplicate() { + prepareStoragePersisterMock() + + editor.remove("storage-key") + editor.putString("storage-key", "storage-value") + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value")) + verify(storagePersisterOps).remove("storage-key") + verify(storagePersisterOps).put("storage-key", "storage-value") + verifyNoMoreInteractions(storagePersisterOps) + } + + @Test + fun `remove key that doesn't exist`() { + prepareStoragePersisterMock() + + editor.remove("x") + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEqualTo(mapOf("storage-key" to "storage-value")) + verify(storagePersisterOps).remove("x") + verifyNoMoreInteractions(storagePersisterOps) + } + + @Test + fun remove() { + prepareStoragePersisterMock() + + editor.remove("storage-key") + val success = editor.commit() + + assertThat(success).isTrue() + assertThat(newValues).isEmpty() + verify(storagePersisterOps).remove("storage-key") + verifyNoMoreInteractions(storagePersisterOps) + } + + private fun prepareStoragePersisterMock() { + stubbing(storagePersisterOps) { + on { put(any(), any()) } doAnswer { + val key = it.getArgument<String>(0) + val value = it.getArgument<String>(1) + workingMap[key] = value + } + + on { remove(any()) } doAnswer { + val key = it.getArgument<String>(0) + workingMap.remove(key) + + Unit + } + } + + stubbing(storagePersister) { + on { doInTransaction(any()) } doAnswer { + val operationCallback = it.getArgument<StoragePersistOperationCallback>(0) + + operationCallback.beforePersistTransaction(workingMap) + operationCallback.persist(storagePersisterOps) + operationCallback.onPersistTransactionSuccess(workingMap) + } + } + } +} + +class TestStorageUpdater(private val currentStorage: Storage) : StorageUpdater { + var newStorage: Storage? = null + + override fun updateStorage(updater: (currentStorage: Storage) -> Storage) { + newStorage = updater(currentStorage) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt b/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt new file mode 100644 index 0000000..95898e1 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/preferences/StoragePersisterTest.kt @@ -0,0 +1,120 @@ +package com.fsck.k9.preferences + +import android.content.Context +import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperationCallback +import com.fsck.k9.preferences.K9StoragePersister.StoragePersistOperations +import com.fsck.k9.storage.K9RobolectricTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.robolectric.RuntimeEnvironment + +class StoragePersisterTest : K9RobolectricTest() { + private var context: Context = RuntimeEnvironment.getApplication() + private var storagePersister = K9StoragePersister(context) + + @Test + fun doInTransaction_order() { + val operationCallback = prepareCallback() + storagePersister.doInTransaction(operationCallback) + + inOrder(operationCallback) { + verify(operationCallback).beforePersistTransaction(any()) + verify(operationCallback).persist(any()) + verify(operationCallback).onPersistTransactionSuccess(any()) + } + verifyNoMoreInteractions(operationCallback) + } + + @Test + fun doInTransaction_put() { + val operationCallback = prepareCallback( + persistOp = { ops -> ops.put("x", "y") }, + onSuccess = { map -> + assertEquals(1, map.size) + assertEquals("y", map["x"]) + } + ) + + storagePersister.doInTransaction(operationCallback) + + val values = storagePersister.loadValues().all + assertEquals(1, values.size) + assertEquals("y", values["x"]) + } + + @Test + fun doInTransaction_putAndThrow() { + val exception = Exception("boom") + val operationCallback = prepareCallback( + persistOp = { ops -> + ops.put("x", "y") + throw exception + } + ) + + try { + storagePersister.doInTransaction(operationCallback) + fail("expected exception") + } catch (e: Exception) { + assertSame(exception, e) + } + + val values = storagePersister.loadValues() + assertTrue(values.isEmpty()) + verify(operationCallback, never()).onPersistTransactionSuccess(any()) + } + + @Test + fun doInTransaction_remove() { + val operationCallback = prepareCallback( + before = { map -> map["x"] = "y" }, + persistOp = { ops -> ops.remove("x") }, + onSuccess = { map -> assertTrue(map.isEmpty()) } + ) + + storagePersister.doInTransaction(operationCallback) + + val values = storagePersister.loadValues() + assertTrue(values.isEmpty()) + } + + @Test + fun doInTransaction_before_preserveButNotPersist() { + val operationCallback = prepareCallback( + before = { map -> map["x"] = "y" }, + onSuccess = { map -> assertEquals("y", map["x"]) } + ) + + storagePersister.doInTransaction(operationCallback) + + val values = storagePersister.loadValues() + assertTrue(values.isEmpty()) + } + + private fun prepareCallback( + persistOp: ((StoragePersistOperations) -> Unit)? = null, + before: ((MutableMap<String, String>) -> Unit)? = null, + onSuccess: ((Map<String, String>) -> Unit)? = null + ): StoragePersistOperationCallback = spy(object : StoragePersistOperationCallback { + override fun beforePersistTransaction(workingStorage: MutableMap<String, String>) { + before?.invoke(workingStorage) + } + + override fun persist(ops: StoragePersistOperations) { + persistOp?.invoke(ops) + } + + override fun onPersistTransactionSuccess(workingStorage: Map<String, String>) { + onSuccess?.invoke(workingStorage) + } + }) +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/K9RobolectricTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/K9RobolectricTest.kt new file mode 100644 index 0000000..eeb24ec --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/K9RobolectricTest.kt @@ -0,0 +1,16 @@ +package com.fsck.k9.storage + +import android.app.Application +import org.junit.runner.RunWith +import org.koin.test.AutoCloseKoinTest +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A Robolectric test that creates an instance of our [Application] test class [TestApp]. + * + * See also [RobolectricTest]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApp::class) +abstract class K9RobolectricTest : AutoCloseKoinTest() diff --git a/app/storage/src/test/java/com/fsck/k9/storage/RobolectricTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/RobolectricTest.kt new file mode 100644 index 0000000..0e40c60 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/RobolectricTest.kt @@ -0,0 +1,15 @@ +package com.fsck.k9.storage + +import android.app.Application +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A Robolectric test that does not create an instance of our [Application] class. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = EmptyApplication::class) +abstract class RobolectricTest + +class EmptyApplication : Application() diff --git a/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java b/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java new file mode 100644 index 0000000..325eac1 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java @@ -0,0 +1,424 @@ +package com.fsck.k9.storage; + + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import android.app.Application; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.text.TextUtils; + +import com.fsck.k9.Account; +import com.fsck.k9.core.BuildConfig; +import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mail.ConnectionSecurity; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.ServerSettings; +import com.fsck.k9.mailstore.LocalStore; +import com.fsck.k9.mailstore.LockableDatabase; +import com.fsck.k9.mailstore.MigrationsHelper; +import com.fsck.k9.mailstore.StorageManager; +import org.junit.Before; +import org.junit.Test; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.shadows.ShadowLog; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + +public class StoreSchemaDefinitionTest extends K9RobolectricTest { + private StoreSchemaDefinition storeSchemaDefinition; + + + @Before + public void setUp() throws MessagingException { + ShadowLog.stream = System.out; + + Application application = RuntimeEnvironment.getApplication(); + StorageManager.getInstance(application); + + storeSchemaDefinition = createStoreSchemaDefinition(); + } + + @Test + public void getVersion_shouldReturnCurrentDatabaseVersion() { + int version = storeSchemaDefinition.getVersion(); + + assertEquals(StoreSchemaDefinition.DB_VERSION, version); + } + + @Test + public void doDbUpgrade_withEmptyDatabase_shouldSetsDatabaseVersion() { + SQLiteDatabase database = SQLiteDatabase.create(null); + + storeSchemaDefinition.doDbUpgrade(database); + + assertEquals(StoreSchemaDefinition.DB_VERSION, database.getVersion()); + } + + @Test + public void doDbUpgrade_withBadDatabase_shouldThrowInDebugBuild() { + if (BuildConfig.DEBUG) { + SQLiteDatabase database = SQLiteDatabase.create(null); + database.setVersion(61); + + try { + storeSchemaDefinition.doDbUpgrade(database); + fail("Expected Error"); + } catch (Error e) { + assertEquals("Exception while upgrading database", e.getMessage()); + } + } + } + + @Test + public void doDbUpgrade_withV61_shouldUpgradeDatabaseToLatestVersion() { + SQLiteDatabase database = createV61Database(); + + storeSchemaDefinition.doDbUpgrade(database); + + assertEquals(StoreSchemaDefinition.DB_VERSION, database.getVersion()); + } + + @Test + public void doDbUpgrade_withV61() { + SQLiteDatabase database = createV61Database(); + insertMessageWithSubject(database, "Test Email"); + + storeSchemaDefinition.doDbUpgrade(database); + + assertMessageWithSubjectExists(database, "Test Email"); + } + + @Test + public void doDbUpgrade_fromV61_shouldResultInSameTables() { + SQLiteDatabase newDatabase = createNewDatabase(); + SQLiteDatabase upgradedDatabase = createV61Database(); + + storeSchemaDefinition.doDbUpgrade(upgradedDatabase); + + assertDatabaseTablesEquals(newDatabase, upgradedDatabase); + } + + @Test + public void doDbUpgrade_fromV61_shouldResultInSameTriggers() { + SQLiteDatabase newDatabase = createNewDatabase(); + SQLiteDatabase upgradedDatabase = createV61Database(); + + storeSchemaDefinition.doDbUpgrade(upgradedDatabase); + + assertDatabaseTriggersEquals(newDatabase, upgradedDatabase); + } + + @Test + public void doDbUpgrade_fromV61_shouldResultInSameIndexes() { + SQLiteDatabase newDatabase = createNewDatabase(); + SQLiteDatabase upgradedDatabase = createV61Database(); + + storeSchemaDefinition.doDbUpgrade(upgradedDatabase); + + assertDatabaseIndexesEquals(newDatabase, upgradedDatabase); + } + + + private SQLiteDatabase createV61Database() { + SQLiteDatabase database = SQLiteDatabase.create(null); + initV61Database(database); + return database; + } + + private void initV61Database(SQLiteDatabase db) { + + db.beginTransaction(); + + db.execSQL("DROP TABLE IF EXISTS folders"); + db.execSQL("CREATE TABLE folders (" + + "id INTEGER PRIMARY KEY," + + "name TEXT, " + + "last_updated INTEGER, " + + "unread_count INTEGER, " + + "visible_limit INTEGER, " + + "status TEXT, " + + "push_state TEXT, " + + "last_pushed INTEGER, " + + "flagged_count INTEGER default 0, " + + "integrate INTEGER, " + + "top_group INTEGER, " + + "poll_class TEXT, " + + "push_class TEXT, " + + "display_class TEXT, " + + "notify_class TEXT default 'INHERITED', " + + "more_messages TEXT default \"unknown\"" + + ")"); + + db.execSQL("CREATE INDEX IF NOT EXISTS folder_name ON folders (name)"); + db.execSQL("DROP TABLE IF EXISTS messages"); + db.execSQL("CREATE TABLE messages (" + + "id INTEGER PRIMARY KEY, " + + "deleted INTEGER default 0, " + + "folder_id INTEGER, " + + "uid TEXT, " + + "subject TEXT, " + + "date INTEGER, " + + "flags TEXT, " + + "sender_list TEXT, " + + "to_list TEXT, " + + "cc_list TEXT, " + + "bcc_list TEXT, " + + "reply_to_list TEXT, " + + "attachment_count INTEGER, " + + "internal_date INTEGER, " + + "message_id TEXT, " + + "preview_type TEXT default \"none\", " + + "preview TEXT, " + + "mime_type TEXT, "+ + "normalized_subject_hash INTEGER, " + + "empty INTEGER default 0, " + + "read INTEGER default 0, " + + "flagged INTEGER default 0, " + + "answered INTEGER default 0, " + + "forwarded INTEGER default 0, " + + "message_part_id INTEGER" + + ")"); + + db.execSQL("DROP TABLE IF EXISTS message_parts"); + db.execSQL("CREATE TABLE message_parts (" + + "id INTEGER PRIMARY KEY, " + + "type INTEGER NOT NULL, " + + "root INTEGER, " + + "parent INTEGER NOT NULL, " + + "seq INTEGER NOT NULL, " + + "mime_type TEXT, " + + "decoded_body_size INTEGER, " + + "display_name TEXT, " + + "header TEXT, " + + "encoding TEXT, " + + "charset TEXT, " + + "data_location INTEGER NOT NULL, " + + "data BLOB, " + + "preamble TEXT, " + + "epilogue TEXT, " + + "boundary TEXT, " + + "content_id TEXT, " + + "server_extra TEXT" + + ")"); + + db.execSQL("CREATE TRIGGER set_message_part_root " + + "AFTER INSERT ON message_parts " + + "BEGIN " + + "UPDATE message_parts SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " + + "END"); + + db.execSQL("CREATE INDEX IF NOT EXISTS msg_uid ON messages (uid, folder_id)"); + db.execSQL("DROP INDEX IF EXISTS msg_folder_id"); + db.execSQL("DROP INDEX IF EXISTS msg_folder_id_date"); + db.execSQL("CREATE INDEX IF NOT EXISTS msg_folder_id_deleted_date ON messages (folder_id,deleted,internal_date)"); + + db.execSQL("DROP INDEX IF EXISTS msg_empty"); + db.execSQL("CREATE INDEX IF NOT EXISTS msg_empty ON messages (empty)"); + + db.execSQL("DROP INDEX IF EXISTS msg_read"); + db.execSQL("CREATE INDEX IF NOT EXISTS msg_read ON messages (read)"); + + db.execSQL("DROP INDEX IF EXISTS msg_flagged"); + db.execSQL("CREATE INDEX IF NOT EXISTS msg_flagged ON messages (flagged)"); + + db.execSQL("DROP INDEX IF EXISTS msg_composite"); + db.execSQL("CREATE INDEX IF NOT EXISTS msg_composite ON messages (deleted, empty,folder_id,flagged,read)"); + + + db.execSQL("DROP TABLE IF EXISTS threads"); + db.execSQL("CREATE TABLE threads (" + + "id INTEGER PRIMARY KEY, " + + "message_id INTEGER, " + + "root INTEGER, " + + "parent INTEGER" + + ")"); + + db.execSQL("DROP INDEX IF EXISTS threads_message_id"); + db.execSQL("CREATE INDEX IF NOT EXISTS threads_message_id ON threads (message_id)"); + + db.execSQL("DROP INDEX IF EXISTS threads_root"); + db.execSQL("CREATE INDEX IF NOT EXISTS threads_root ON threads (root)"); + + db.execSQL("DROP INDEX IF EXISTS threads_parent"); + db.execSQL("CREATE INDEX IF NOT EXISTS threads_parent ON threads (parent)"); + + db.execSQL("DROP TRIGGER IF EXISTS set_thread_root"); + db.execSQL("CREATE TRIGGER set_thread_root " + + "AFTER INSERT ON threads " + + "BEGIN " + + "UPDATE threads SET root=id WHERE root IS NULL AND ROWID = NEW.ROWID; " + + "END"); + + db.execSQL("DROP TABLE IF EXISTS pending_commands"); + db.execSQL("CREATE TABLE pending_commands " + + "(id INTEGER PRIMARY KEY, command TEXT, data TEXT)"); + + db.execSQL("DROP TRIGGER IF EXISTS delete_folder"); + db.execSQL("CREATE TRIGGER delete_folder BEFORE DELETE ON folders BEGIN DELETE FROM messages WHERE old.id = folder_id; END;"); + + db.execSQL("DROP TRIGGER IF EXISTS delete_message"); + db.execSQL("CREATE TRIGGER delete_message " + + "BEFORE DELETE ON messages " + + "BEGIN " + + "DELETE FROM message_parts WHERE root = OLD.message_part_id; " + + "DELETE FROM messages_fulltext WHERE docid = OLD.id; " + + "END"); + + db.execSQL("DROP TABLE IF EXISTS messages_fulltext"); + db.execSQL("CREATE VIRTUAL TABLE messages_fulltext USING fts4 (fulltext)"); + + db.setVersion(61); + + db.setTransactionSuccessful(); + db.endTransaction(); + } + + private void assertMessageWithSubjectExists(SQLiteDatabase database, String subject) { + Cursor cursor = database.query("messages", new String[] { "subject" }, null, null, null, null, null); + try { + assertTrue(cursor.moveToFirst()); + assertEquals(subject, cursor.getString(0)); + } finally { + cursor.close(); + } + } + + private void assertDatabaseTablesEquals(SQLiteDatabase expected, SQLiteDatabase actual) { + List<String> tablesInNewDatabase = tablesInDatabase(expected); + Collections.sort(tablesInNewDatabase); + + List<String> tablesInUpgradedDatabase = tablesInDatabase(actual); + Collections.sort(tablesInUpgradedDatabase); + + assertEquals(tablesInNewDatabase, tablesInUpgradedDatabase); + } + + private void assertDatabaseTriggersEquals(SQLiteDatabase expected, SQLiteDatabase actual) { + List<String> triggersInNewDatabase = triggersInDatabase(expected); + Collections.sort(triggersInNewDatabase); + + List<String> triggersInUpgradedDatabase = triggersInDatabase(actual); + Collections.sort(triggersInUpgradedDatabase); + + assertEquals(triggersInNewDatabase, triggersInUpgradedDatabase); + } + + private void assertDatabaseIndexesEquals(SQLiteDatabase expected, SQLiteDatabase actual) { + List<String> indexesInNewDatabase = indexesInDatabase(expected); + Collections.sort(indexesInNewDatabase); + + List<String> indexesInUpgradedDatabase = indexesInDatabase(actual); + Collections.sort(indexesInUpgradedDatabase); + + assertEquals(indexesInNewDatabase, indexesInUpgradedDatabase); + } + + private List<String> tablesInDatabase(SQLiteDatabase db) { + return objectsInDatabase(db, "table"); + } + + private List<String> triggersInDatabase(SQLiteDatabase db) { + return objectsInDatabase(db, "trigger"); + } + + private List<String> indexesInDatabase(SQLiteDatabase db) { + return objectsInDatabase(db, "index"); + } + + private List<String> objectsInDatabase(SQLiteDatabase db, String type) { + List<String> databaseObjects = new ArrayList<>(); + Cursor cursor = db.rawQuery("SELECT sql FROM sqlite_master WHERE type = ? AND sql IS NOT NULL", + new String[] { type }); + try { + while (cursor.moveToNext()) { + String sql = cursor.getString(cursor.getColumnIndex("sql")); + String resortedSql = "table".equals(type) ? sortTableColumns(sql) : sql; + databaseObjects.add(resortedSql); + } + } finally { + cursor.close(); + } + + return databaseObjects; + } + + private String sortTableColumns(String sql) { + int positionOfColumnDefinitions = sql.indexOf('('); + String columnDefinitionsSql = sql.substring(positionOfColumnDefinitions + 1, sql.length() - 1); + String[] columnDefinitions = columnDefinitionsSql.split(" *, *(?![^(]*\\))"); + Arrays.sort(columnDefinitions); + + String sqlPrefix = sql.substring(0, positionOfColumnDefinitions + 1); + String sortedColumnDefinitionsSql = TextUtils.join(", ", columnDefinitions); + return sqlPrefix + sortedColumnDefinitionsSql + ")"; + } + + private void insertMessageWithSubject(SQLiteDatabase database, String subject) { + ContentValues data = new ContentValues(); + data.put("subject", subject); + long rowId = database.insert("messages", null, data); + assertNotEquals(-1, rowId); + } + + private StoreSchemaDefinition createStoreSchemaDefinition() throws MessagingException { + final Account account = createAccount(); + final LockableDatabase lockableDatabase = createLockableDatabase(); + final LocalStore localStore = mock(LocalStore.class); + when(localStore.getDatabase()).thenReturn(lockableDatabase); + + MigrationsHelper migrationsHelper = new MigrationsHelper() { + @Override + public Account getAccount() { + return account; + } + + @Override + public void saveAccount() { + // Do nothing + } + }; + + return new StoreSchemaDefinition(migrationsHelper); + } + + private LockableDatabase createLockableDatabase() throws MessagingException { + LockableDatabase lockableDatabase = mock(LockableDatabase.class); + when(lockableDatabase.execute(anyBoolean(), any(LockableDatabase.DbCallback.class))).thenReturn(false); + return lockableDatabase; + } + + private Account createAccount() { + Account account = mock(Account.class); + when(account.getLegacyInboxFolder()).thenReturn("Inbox"); + when(account.getImportedTrashFolder()).thenReturn("Trash"); + when(account.getImportedDraftsFolder()).thenReturn("Drafts"); + when(account.getImportedSpamFolder()).thenReturn("Spam"); + when(account.getImportedSentFolder()).thenReturn("Sent"); + when(account.getImportedArchiveFolder()).thenReturn(null); + when(account.getLocalStorageProviderId()).thenReturn(StorageManager.InternalStorageProvider.ID); + + ServerSettings incomingServerSettings = new ServerSettings("dummy", "", -1, ConnectionSecurity.NONE, + AuthType.AUTOMATIC, "", "", null); + when(account.getIncomingServerSettings()).thenReturn(incomingServerSettings); + return account; + } + + private SQLiteDatabase createNewDatabase() { + SQLiteDatabase database = SQLiteDatabase.create(null); + storeSchemaDefinition.doDbUpgrade(database); + return database; + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt b/app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt new file mode 100644 index 0000000..97dbdb0 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt @@ -0,0 +1,35 @@ +package com.fsck.k9.storage + +import android.app.Application +import com.fsck.k9.AppConfig +import com.fsck.k9.Core +import com.fsck.k9.CoreResourceProvider +import com.fsck.k9.DI +import com.fsck.k9.K9 +import com.fsck.k9.backend.BackendManager +import com.fsck.k9.coreModules +import com.fsck.k9.crypto.EncryptionExtractor +import com.fsck.k9.preferences.K9StoragePersister +import com.fsck.k9.preferences.StoragePersister +import org.koin.dsl.module +import org.mockito.kotlin.mock + +class TestApp : Application() { + override fun onCreate() { + Core.earlyInit() + + super.onCreate() + DI.start(this, coreModules + storageModule + testModule) + + K9.init(this) + Core.init(this) + } +} + +val testModule = module { + single { AppConfig(emptyList()) } + single { mock<CoreResourceProvider>() } + single { mock<EncryptionExtractor>() } + single<StoragePersister> { K9StoragePersister(get()) } + single { mock<BackendManager>() } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt new file mode 100644 index 0000000..1c25c54 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt @@ -0,0 +1,65 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test + +class CheckFolderOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val checkFolderOperations = CheckFolderOperations(lockableDatabase) + + @Test + fun `single folder not included in Unified Inbox`() { + val folderIds = listOf(sqliteDatabase.createFolder(integrate = false)) + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isFalse() + } + + @Test + fun `single folder included in Unified Inbox`() { + val folderIds = listOf(sqliteDatabase.createFolder(integrate = true)) + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isTrue() + } + + @Test + fun `not all folders included in Unified Inbox`() { + val folderIds = listOf( + sqliteDatabase.createFolder(integrate = true), + sqliteDatabase.createFolder(integrate = false) + ) + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isFalse() + } + + @Test + fun `1000 folders included in Unified Inbox`() { + val folderIds = List(1000) { + sqliteDatabase.createFolder(integrate = true) + } + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isTrue() + } + + @Test + fun `999 of 1000 folders included in Unified Inbox`() { + val folderIds = List(999) { + sqliteDatabase.createFolder(integrate = true) + } + sqliteDatabase.createFolder(integrate = false) + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isFalse() + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperationsTest.kt new file mode 100644 index 0000000..77dc7b0 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/ChunkedDatabaseOperationsTest.kt @@ -0,0 +1,114 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import org.junit.Assert.fail +import org.junit.Test + +class ChunkedDatabaseOperationsTest { + @Test(expected = IllegalArgumentException::class) + fun `empty list`() { + performChunkedOperation( + arguments = emptyList(), + argumentTransformation = Int::toString, + operation = ::failCallback + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `chunkSize = 0`() { + performChunkedOperation( + arguments = listOf(1), + argumentTransformation = Int::toString, + chunkSize = 0, + operation = ::failCallback + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `chunkSize = 1001`() { + performChunkedOperation( + arguments = listOf(1), + argumentTransformation = Int::toString, + chunkSize = 1001, + operation = ::failCallback + ) + } + + @Test + fun `single item`() { + val chunks = mutableListOf<Pair<String, Array<String>>>() + + performChunkedOperation( + arguments = listOf(1), + argumentTransformation = Int::toString + ) { selectionSet, selectionArguments -> + chunks.add(selectionSet to selectionArguments) + Unit + } + + assertThat(chunks).hasSize(1) + with(chunks.first()) { + assertThat(first).isEqualTo("IN (?)") + assertThat(second).isEqualTo(arrayOf("1")) + } + } + + @Test + fun `2 items with chunk size of 1`() { + val chunks = mutableListOf<Pair<String, Array<String>>>() + + performChunkedOperation( + arguments = listOf(1, 2), + argumentTransformation = Int::toString, + chunkSize = 1 + ) { selectionSet, selectionArguments -> + chunks.add(selectionSet to selectionArguments) + Unit + } + + assertThat(chunks).hasSize(2) + with(chunks[0]) { + assertThat(first).isEqualTo("IN (?)") + assertThat(second).isEqualTo(arrayOf("1")) + } + with(chunks[1]) { + assertThat(first).isEqualTo("IN (?)") + assertThat(second).isEqualTo(arrayOf("2")) + } + } + + @Test + fun `14 items with chunk size of 5`() { + val chunks = mutableListOf<Pair<String, Array<String>>>() + + performChunkedOperation( + arguments = (1..14).toList(), + argumentTransformation = Int::toString, + chunkSize = 5 + ) { selectionSet, selectionArguments -> + chunks.add(selectionSet to selectionArguments) + Unit + } + + assertThat(chunks).hasSize(3) + with(chunks[0]) { + assertThat(first).isEqualTo("IN (?,?,?,?,?)") + assertThat(second).isEqualTo(arrayOf("1", "2", "3", "4", "5")) + } + with(chunks[1]) { + assertThat(first).isEqualTo("IN (?,?,?,?,?)") + assertThat(second).isEqualTo(arrayOf("6", "7", "8", "9", "10")) + } + with(chunks[2]) { + assertThat(first).isEqualTo("IN (?,?,?,?)") + assertThat(second).isEqualTo(arrayOf("11", "12", "13", "14")) + } + } + + @Suppress("UNUSED_PARAMETER") + private fun failCallback(selectionSet: String, selectionArguments: Array<String>) { + fail("'operation' callback called when it shouldn't") + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/CopyMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/CopyMessageOperationsTest.kt new file mode 100644 index 0000000..84ac34e --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/CopyMessageOperationsTest.kt @@ -0,0 +1,252 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNotIn +import assertk.assertions.isNull +import com.fsck.k9.mail.crlf +import com.fsck.k9.mailstore.StorageManager +import com.fsck.k9.storage.RobolectricTest +import okio.buffer +import okio.sink +import okio.source +import org.junit.After +import org.junit.Test +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock + +private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000" + +class CopyMessageOperationsTest : RobolectricTest() { + private val messagePartDirectory = createRandomTempDirectory() + private val sqliteDatabase = createDatabase() + private val storageManager = mock<StorageManager> { + on { getAttachmentDirectory(eq(ACCOUNT_UUID), anyOrNull()) } doReturn messagePartDirectory + } + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val attachmentFileManager = AttachmentFileManager(storageManager, ACCOUNT_UUID) + private val threadMessageOperations = ThreadMessageOperations() + private val copyMessageOperations = CopyMessageOperations( + lockableDatabase, + attachmentFileManager, + threadMessageOperations + ) + + @After + fun tearDown() { + messagePartDirectory.deleteRecursively() + } + + @Test + fun `copy message that is part of a thread`() { + val sourceMessagePartId1 = sqliteDatabase.createMessagePart( + seq = 0, + dataLocation = DataLocation.CHILD_PART_CONTAINS_DATA, + mimeType = "multipart/mixed", + boundary = "--boundary", + header = "Message-ID: <msg0002@domain.example>\nIn-Reply-To: <msg0001@domain.example>\n".crlf() + ) + val sourceMessagePartId2 = sqliteDatabase.createMessagePart( + seq = 1, + root = sourceMessagePartId1, + parent = sourceMessagePartId1, + mimeType = "text/plain", + dataLocation = DataLocation.IN_DATABASE, + data = "Text part".toByteArray() + ) + val sourceMessagePartId3 = sqliteDatabase.createMessagePart( + seq = 2, + root = sourceMessagePartId1, + parent = sourceMessagePartId1, + mimeType = "application/octet-stream", + dataLocation = DataLocation.ON_DISK + ) + attachmentFileManager.getAttachmentFile(sourceMessagePartId3).sink().buffer().use { sink -> + sink.writeUtf8("Part contents") + } + + val messageId1 = sqliteDatabase.createMessage( + folderId = 1, + empty = true, + messageIdHeader = "<msg0001@domain.example>" + ) + val messageId2 = sqliteDatabase.createMessage( + folderId = 1, + empty = false, + messageIdHeader = "<msg0002@domain.example>", + messagePartId = sourceMessagePartId1 + ) + val messageId3 = sqliteDatabase.createMessage( + folderId = 1, + empty = false, + messageIdHeader = "<msg0003@domain.example>" + ) + val threadId1 = sqliteDatabase.createThread(messageId1) + val threadId2 = sqliteDatabase.createThread(messageId2, root = threadId1, parent = threadId1) + val threadId3 = sqliteDatabase.createThread(messageId3, root = threadId1, parent = threadId2) + + val destinationMessageId = copyMessageOperations.copyMessage(messageId = messageId2, destinationFolderId = 2) + + assertThat(destinationMessageId).isNotIn(setOf(messageId1, messageId2, messageId3)) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(3 + 2) + + val destinationMessageThread = threads.first { it.messageId == destinationMessageId } + assertThat(destinationMessageThread.id).isNotIn(setOf(threadId1, threadId2, threadId3)) + assertThat(destinationMessageThread.parent).isEqualTo(destinationMessageThread.root) + + val destinationRootThread = threads.first { it.id == destinationMessageThread.root } + assertThat(destinationRootThread.messageId).isNotIn(setOf(messageId1, messageId2, messageId3)) + assertThat(destinationRootThread.root).isEqualTo(destinationRootThread.id) + assertThat(destinationRootThread.parent).isNull() + + val messages = sqliteDatabase.readMessages() + val destinationRootThreadMessage = messages.first { it.id == destinationRootThread.messageId } + assertThat(destinationRootThreadMessage.empty).isEqualTo(1) + assertThat(destinationRootThreadMessage.folderId).isEqualTo(2) + assertThat(destinationRootThreadMessage.messageId).isEqualTo("<msg0001@domain.example>") + + val destinationMessage = messages.first { it.id == destinationMessageThread.messageId } + val sourceMessage = messages.first { it.id == messageId2 } + assertThat(destinationMessage).isEqualTo( + sourceMessage.copy( + id = destinationMessageId, + uid = destinationMessage.uid, + folderId = 2, + messagePartId = destinationMessage.messagePartId + ) + ) + + val messageParts = sqliteDatabase.readMessageParts() + assertThat(messageParts).hasSize(3 + 3) + + val sourceMessagePart1 = messageParts.first { it.id == sourceMessagePartId1 } + val sourceMessagePart2 = messageParts.first { it.id == sourceMessagePartId2 } + val sourceMessagePart3 = messageParts.first { it.id == sourceMessagePartId3 } + val destinationMessagePart1 = messageParts.first { it.id == destinationMessage.messagePartId } + val destinationMessagePart2 = messageParts.first { it.root == destinationMessage.messagePartId && it.seq == 1 } + val destinationMessagePart3 = messageParts.first { it.root == destinationMessage.messagePartId && it.seq == 2 } + assertThat(destinationMessagePart1).isNotIn(setOf(sourceMessagePart1, sourceMessagePart2, sourceMessagePart3)) + assertThat(destinationMessagePart1).isEqualTo( + sourceMessagePart1.copy( + id = destinationMessagePart1.id, + root = destinationMessagePart1.id, + parent = -1 + ) + ) + assertThat(destinationMessagePart2).isNotIn(setOf(sourceMessagePart1, sourceMessagePart2, sourceMessagePart3)) + assertThat(destinationMessagePart2).isEqualTo( + sourceMessagePart2.copy( + id = destinationMessagePart2.id, + root = destinationMessagePart1.id, + parent = destinationMessagePart1.id + ) + ) + assertThat(destinationMessagePart3).isNotIn(setOf(sourceMessagePart1, sourceMessagePart2, sourceMessagePart3)) + assertThat(destinationMessagePart3).isEqualTo( + sourceMessagePart3.copy( + id = destinationMessagePart3.id, + root = destinationMessagePart1.id, + parent = destinationMessagePart1.id + ) + ) + + val files = messagePartDirectory.list()?.toList() ?: emptyList() + assertThat(files).hasSize(2) + + attachmentFileManager.getAttachmentFile(destinationMessagePart3.id!!).source().buffer().use { source -> + assertThat(source.readUtf8()).isEqualTo("Part contents") + } + } + + @Test + fun `copy message into an existing thread`() { + val sourceMessagePartId = sqliteDatabase.createMessagePart( + header = "Message-ID: <msg0002@domain.example>\nIn-Reply-To: <msg0001@domain.example>\n".crlf(), + mimeType = "text/plain", + dataLocation = DataLocation.IN_DATABASE, + data = "Text part".toByteArray() + ) + attachmentFileManager.getAttachmentFile(sourceMessagePartId).sink().buffer().use { sink -> + sink.writeUtf8("Part contents") + } + + val sourceMessageId = sqliteDatabase.createMessage( + folderId = 1, + empty = false, + messageIdHeader = "<msg0002@domain.example>", + messagePartId = sourceMessagePartId + ) + val destinationMessageId = sqliteDatabase.createMessage( + folderId = 2, + empty = true, + messageIdHeader = "<msg0002@domain.example>" + ) + val otherDestinationMessageId = sqliteDatabase.createMessage( + folderId = 2, + empty = false, + messageIdHeader = "<msg0003@domain.example>" + ) + val destinationThreadId = sqliteDatabase.createThread(destinationMessageId) + val otherDestinationThreadId = sqliteDatabase.createThread( + otherDestinationMessageId, + root = destinationThreadId, + parent = destinationThreadId + ) + + val resultMessageId = copyMessageOperations.copyMessage(messageId = sourceMessageId, destinationFolderId = 2) + + assertThat(resultMessageId).isEqualTo(destinationMessageId) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(2 + 1) + + val destinationThread = threads.first { it.messageId == destinationMessageId } + assertThat(destinationThread.id).isEqualTo(destinationThreadId) + assertThat(destinationThread.parent).isEqualTo(destinationThread.root) + + val destinationRootThread = threads.first { it.id == destinationThread.root } + assertThat(destinationRootThread.root).isEqualTo(destinationRootThread.id) + assertThat(destinationRootThread.parent).isNull() + + val otherDestinationThread = threads.first { it.id == otherDestinationThreadId } + assertThat(otherDestinationThread.root).isEqualTo(destinationRootThread.id) + assertThat(otherDestinationThread.parent).isEqualTo(destinationThread.id) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(3 + 1) + + val destinationRootThreadMessage = messages.first { it.id == destinationRootThread.messageId } + assertThat(destinationRootThreadMessage.empty).isEqualTo(1) + assertThat(destinationRootThreadMessage.folderId).isEqualTo(2) + assertThat(destinationRootThreadMessage.messageId).isEqualTo("<msg0001@domain.example>") + + val destinationMessage = messages.first { it.id == destinationMessageId } + val sourceMessage = messages.first { it.id == sourceMessageId } + assertThat(destinationMessage).isEqualTo( + sourceMessage.copy( + id = destinationMessageId, + uid = destinationMessage.uid, + folderId = 2, + messagePartId = destinationMessage.messagePartId + ) + ) + + val messageParts = sqliteDatabase.readMessageParts() + assertThat(messageParts).hasSize(1 + 1) + + val sourceMessagePart = messageParts.first { it.id == sourceMessagePartId } + val destinationMessagePart = messageParts.first { it.id == destinationMessage.messagePartId } + assertThat(destinationMessagePart).isEqualTo( + sourceMessagePart.copy( + id = destinationMessagePart.id, + root = destinationMessagePart.id, + parent = -1 + ) + ) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/CreateFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/CreateFolderOperationsTest.kt new file mode 100644 index 0000000..d3af642 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/CreateFolderOperationsTest.kt @@ -0,0 +1,91 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.FolderClass +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mailstore.CreateFolderInfo +import com.fsck.k9.mailstore.FolderSettings +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test + +class CreateFolderOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val createFolderOperations = CreateFolderOperations(lockableDatabase) + + @Test + fun `create single folder`() { + createFolderOperations.createFolders( + listOf( + CreateFolderInfo( + serverId = "archived_messages", + name = "Archive", + type = FolderType.ARCHIVE, + settings = FolderSettings( + visibleLimit = 10, + displayClass = FolderClass.FIRST_CLASS, + syncClass = FolderClass.SECOND_CLASS, + notifyClass = FolderClass.NO_CLASS, + pushClass = FolderClass.NO_CLASS, + inTopGroup = true, + integrate = false + ) + ) + ) + ) + + val folders = sqliteDatabase.readFolders() + assertThat(folders).hasSize(1) + val folder = folders.first() + assertThat(folder.serverId).isEqualTo("archived_messages") + assertThat(folder.name).isEqualTo("Archive") + assertThat(folder.type).isEqualTo("archive") + assertThat(folder.visibleLimit).isEqualTo(10) + assertThat(folder.displayClass).isEqualTo("FIRST_CLASS") + assertThat(folder.syncClass).isEqualTo("SECOND_CLASS") + assertThat(folder.notifyClass).isEqualTo("NO_CLASS") + assertThat(folder.pushClass).isEqualTo("NO_CLASS") + assertThat(folder.inTopGroup).isEqualTo(1) + assertThat(folder.integrate).isEqualTo(0) + } + + @Test + fun `create multiple folders`() { + createFolderOperations.createFolders( + listOf( + createCreateFolderInfo(serverId = "folder1", name = "Inbox"), + createCreateFolderInfo(serverId = "folder2", name = "Sent"), + createCreateFolderInfo(serverId = "folder3", name = "Trash") + ) + ) + + val folders = sqliteDatabase.readFolders() + assertThat(folders).hasSize(3) + assertThat(folders.map { it.serverId to it.name }.toSet()).isEqualTo( + setOf( + "folder1" to "Inbox", + "folder2" to "Sent", + "folder3" to "Trash" + ) + ) + } + + fun createCreateFolderInfo(serverId: String, name: String): CreateFolderInfo { + return CreateFolderInfo( + serverId = serverId, + name = name, + type = FolderType.REGULAR, + settings = FolderSettings( + visibleLimit = 25, + displayClass = FolderClass.NO_CLASS, + syncClass = FolderClass.INHERITED, + notifyClass = FolderClass.INHERITED, + pushClass = FolderClass.NO_CLASS, + inTopGroup = false, + integrate = false + ) + ) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteFolderOperationsTest.kt new file mode 100644 index 0000000..9726ec4 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteFolderOperationsTest.kt @@ -0,0 +1,62 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.extracting +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import com.fsck.k9.mailstore.StorageManager +import com.fsck.k9.storage.RobolectricTest +import org.junit.After +import org.junit.Test +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock + +private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000" + +class DeleteFolderOperationsTest : RobolectricTest() { + private val messagePartDirectory = createRandomTempDirectory() + private val sqliteDatabase = createDatabase() + private val storageManager = mock<StorageManager> { + on { getAttachmentDirectory(eq(ACCOUNT_UUID), anyOrNull()) } doReturn messagePartDirectory + } + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val attachmentFileManager = AttachmentFileManager(storageManager, ACCOUNT_UUID) + private val deleteFolderOperations = DeleteFolderOperations(lockableDatabase, attachmentFileManager) + + @After + fun tearDown() { + messagePartDirectory.deleteRecursively() + } + + @Test + fun `delete folder should remove message part files`() { + createFolderWithMessage("delete", "message1") + val messagePartId = createFolderWithMessage("retain", "message2") + + deleteFolderOperations.deleteFolders(listOf("delete")) + + val folders = sqliteDatabase.readFolders() + assertThat(folders).hasSize(1) + assertThat(folders.first().serverId).isEqualTo("retain") + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + assertThat(messages.first().uid).isEqualTo("message2") + + val messagePartFiles = messagePartDirectory.listFiles() + assertThat(messagePartFiles).isNotNull() + .extracting { it.name }.containsExactly(messagePartId.toString()) + } + + private fun createFolderWithMessage(folderServerId: String, messageServerId: String): Long { + val folderId = sqliteDatabase.createFolder(serverId = folderServerId) + val messagePartId = sqliteDatabase.createMessagePart(dataLocation = 2, directory = messagePartDirectory) + sqliteDatabase.createMessage(folderId = folderId, uid = messageServerId, messagePartId = messagePartId) + + return messagePartId + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteMessageOperationsTest.kt new file mode 100644 index 0000000..68425e7 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/DeleteMessageOperationsTest.kt @@ -0,0 +1,196 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.extracting +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import com.fsck.k9.mailstore.StorageManager +import com.fsck.k9.storage.RobolectricTest +import org.junit.After +import org.junit.Test +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock + +private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000" + +class DeleteMessageOperationsTest : RobolectricTest() { + private val messagePartDirectory = createRandomTempDirectory() + private val sqliteDatabase = createDatabase() + private val storageManager = mock<StorageManager> { + on { getAttachmentDirectory(eq(ACCOUNT_UUID), anyOrNull()) } doReturn messagePartDirectory + } + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val attachmentFileManager = AttachmentFileManager(storageManager, ACCOUNT_UUID) + private val deleteMessageOperations = DeleteMessageOperations(lockableDatabase, attachmentFileManager) + + @After + fun tearDown() { + messagePartDirectory.deleteRecursively() + } + + @Test + fun `destroy message with empty parent messages`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "empty1", + empty = true, + messageIdHeader = "msg001@domain.example" + ) + val messageId2 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "empty2", + empty = true, + messageIdHeader = "msg002@domain.example" + ) + val messageId3 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "delete", + empty = false, + messageIdHeader = "msg003@domain.example" + ) + val threadId1 = sqliteDatabase.createThread(messageId = messageId1) + val threadId2 = sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1) + sqliteDatabase.createThread(messageId = messageId3, root = threadId1, parent = threadId2) + + deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete")) + + assertThat(sqliteDatabase.readMessages()).isEmpty() + assertThat(sqliteDatabase.readThreads()).isEmpty() + } + + @Test + fun `destroy message with empty parent message that has another child`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "empty", + empty = true, + messageIdHeader = "msg001@domain.example" + ) + val messageId2 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "child1", + empty = false, + messageIdHeader = "msg002@domain.example" + ) + val messageId3 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "delete", + empty = false, + messageIdHeader = "msg003@domain.example" + ) + val threadId1 = sqliteDatabase.createThread(messageId = messageId1) + val threadId2 = sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1) + sqliteDatabase.createThread(messageId = messageId3, root = threadId1, parent = threadId1) + + deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete")) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(2) + assertThat(messages.map { it.id }.toSet()).isEqualTo(setOf(messageId1, messageId2)) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(2) + assertThat(threads.map { it.id }.toSet()).isEqualTo(setOf(threadId1, threadId2)) + } + + @Test + fun `destroy message with non-empty parent message`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "parent", + empty = false, + messageIdHeader = "msg001@domain.example" + ) + val messageId2 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "delete", + empty = false, + messageIdHeader = "msg002@domain.example" + ) + val threadId1 = sqliteDatabase.createThread(messageId = messageId1) + sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1) + + deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete")) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + assertThat(messages.first().id).isEqualTo(messageId1) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(1) + assertThat(threads.first().id).isEqualTo(threadId1) + } + + @Test + fun `destroy message without parent message`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "delete", + empty = false, + messageIdHeader = "msg001@domain.example" + ) + sqliteDatabase.createThread(messageId = messageId1) + + deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete")) + + assertThat(sqliteDatabase.readMessages()).isEmpty() + assertThat(sqliteDatabase.readThreads()).isEmpty() + } + + @Test + fun `destroy message with child message should convert to empty message`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "delete", + empty = false, + messageIdHeader = "msg001@domain.example" + ) + val messageId2 = sqliteDatabase.createMessage( + folderId = folderId, + uid = "child", + empty = false, + messageIdHeader = "msg002@domain.example" + ) + val threadId1 = sqliteDatabase.createThread(messageId = messageId1) + val threadId2 = sqliteDatabase.createThread(messageId = messageId2, root = threadId1, parent = threadId1) + + deleteMessageOperations.destroyMessages(folderId = folderId, messageServerIds = listOf("delete")) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(2) + val message1 = messages.first { it.id == messageId1 } + assertThat(message1.empty).isEqualTo(1) + assertThat(message1.subject).isNull() + assertThat(message1.date).isNull() + assertThat(message1.flags).isNull() + assertThat(message1.senderList).isNull() + assertThat(message1.toList).isNull() + assertThat(message1.ccList).isNull() + assertThat(message1.bccList).isNull() + assertThat(message1.replyToList).isNull() + assertThat(message1.attachmentCount).isNull() + assertThat(message1.internalDate).isNull() + assertThat(message1.previewType).isEqualTo("none") + assertThat(message1.preview).isNull() + assertThat(message1.mimeType).isNull() + assertThat(message1.normalizedSubjectHash).isNull() + assertThat(message1.messagePartId).isNull() + assertThat(message1.encryptionType).isNull() + assertThat(messages.firstOrNull { it.id == messageId2 }).isNotNull() + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(2) + assertThat(threads).extracting { it.id }.containsExactlyInAnyOrder(threadId1, threadId2) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/FileHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/FileHelpers.kt new file mode 100644 index 0000000..b78ff32 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/FileHelpers.kt @@ -0,0 +1,10 @@ +package com.fsck.k9.storage.messages + +import java.io.File +import java.util.UUID + +@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") +fun createRandomTempDirectory(): File { + val tempDirectory = File(System.getProperty("java.io.tmpdir", ".")) + return File(tempDirectory, UUID.randomUUID().toString()).also { it.mkdir() } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/FlagMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/FlagMessageOperationsTest.kt new file mode 100644 index 0000000..8438694 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/FlagMessageOperationsTest.kt @@ -0,0 +1,100 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import com.fsck.k9.mail.Flag +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test + +class FlagMessageOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val flagMessageOperations = FlagMessageOperations(lockableDatabase) + + @Test(expected = IllegalArgumentException::class) + fun `empty messageIds list`() { + flagMessageOperations.setFlag(emptyList(), Flag.SEEN, true) + } + + @Test + fun `mark one message as answered`() { + val messageId = sqliteDatabase.createMessage(folderId = 1, uid = "uid1", answered = false) + sqliteDatabase.createMessage(folderId = 1, uid = "uid2", answered = false) + val messageIds = listOf(messageId) + + flagMessageOperations.setFlag(messageIds, Flag.ANSWERED, true) + + val messages = sqliteDatabase.readMessages() + + val message = messages.find { it.id == messageId } ?: error("Original message not found") + assertThat(message.answered).isEqualTo(1) + + val otherMessages = messages.filterNot { it.id == messageId } + assertThat(otherMessages).hasSize(1) + assertThat(otherMessages.all { it.answered == 0 }).isTrue() + } + + @Test + fun `mark multiple messages as read`() { + val messageId1 = sqliteDatabase.createMessage(folderId = 1, uid = "uid1", read = false) + val messageId2 = sqliteDatabase.createMessage(folderId = 1, uid = "uid2", read = false) + val messageId3 = sqliteDatabase.createMessage(folderId = 1, uid = "uid3", read = false) + sqliteDatabase.createMessage(folderId = 1, uid = "uidx", read = false) + val messageIds = listOf(messageId1, messageId2, messageId3) + + flagMessageOperations.setFlag(messageIds, Flag.SEEN, true) + + val messages = sqliteDatabase.readMessages() + + val affectedMessages = messages.filter { it.id in messageIds } + assertThat(affectedMessages).hasSize(3) + assertThat(affectedMessages.all { it.read == 1 }).isTrue() + + val otherMessages = messages.filterNot { it.id in messageIds } + assertThat(otherMessages).hasSize(1) + assertThat(otherMessages.all { it.read == 0 }).isTrue() + } + + @Test + fun `mark message as read`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", read = false) + + flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.SEEN, true) + + val message = sqliteDatabase.readMessages().first() + assertThat(message.read).isEqualTo(1) + } + + @Test + fun `mark message as unread`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", read = true) + + flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.SEEN, false) + + val message = sqliteDatabase.readMessages().first() + assertThat(message.read).isEqualTo(0) + } + + @Test + fun `mark message as X_DOWNLOADED_FULL`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", flags = "X_SUBJECT_DECRYPTED") + + flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.X_DOWNLOADED_FULL, true) + + val message = sqliteDatabase.readMessages().first() + val readFlags = message.flags!!.split(',').toSet() + assertThat(readFlags).isEqualTo(setOf("X_SUBJECT_DECRYPTED", "X_DOWNLOADED_FULL")) + } + + @Test + fun `remove X_DOWNLOADED_FULL flag`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", flags = "X_DOWNLOADED_FULL") + + flagMessageOperations.setMessageFlag(folderId = 1, messageServerId = "uid1", Flag.X_DOWNLOADED_FULL, false) + + val message = sqliteDatabase.readMessages().first() + assertThat(message.flags).isEqualTo("") + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/FolderHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/FolderHelpers.kt new file mode 100644 index 0000000..64fd21e --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/FolderHelpers.kt @@ -0,0 +1,95 @@ +package com.fsck.k9.storage.messages + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import app.k9mail.core.android.common.database.getIntOrNull +import app.k9mail.core.android.common.database.getLongOrNull +import app.k9mail.core.android.common.database.getStringOrNull +import app.k9mail.core.android.common.database.map + +fun SQLiteDatabase.createFolder( + name: String = "irrelevant", + type: String = "regular", + serverId: String? = null, + isLocalOnly: Boolean = true, + integrate: Boolean = false, + inTopGroup: Boolean = false, + displayClass: String = "NO_CLASS", + syncClass: String? = "INHERITED", + notifyClass: String? = "INHERITED", + pushClass: String? = "SECOND_CLASS", + lastUpdated: Long = 0L, + unreadCount: Int = 0, + visibleLimit: Int = 25, + status: String? = null, + flaggedCount: Int = 0, + moreMessages: String = "unknown" +): Long { + val values = ContentValues().apply { + put("name", name) + put("type", type) + put("server_id", serverId) + put("local_only", isLocalOnly) + put("integrate", integrate) + put("top_group", inTopGroup) + put("display_class", displayClass) + put("poll_class", syncClass) + put("notify_class", notifyClass) + put("push_class", pushClass) + put("last_updated", lastUpdated) + put("unread_count", unreadCount) + put("visible_limit", visibleLimit) + put("status", status) + put("flagged_count", flaggedCount) + put("more_messages", moreMessages) + } + + return insert("folders", null, values) +} + +fun SQLiteDatabase.readFolders(): List<FolderEntry> { + val cursor = rawQuery("SELECT * FROM folders", null) + return cursor.use { + cursor.map { + FolderEntry( + id = cursor.getLongOrNull("id"), + name = cursor.getStringOrNull("name"), + type = cursor.getStringOrNull("type"), + serverId = cursor.getStringOrNull("server_id"), + isLocalOnly = cursor.getIntOrNull("local_only"), + integrate = cursor.getIntOrNull("integrate"), + inTopGroup = cursor.getIntOrNull("top_group"), + displayClass = cursor.getStringOrNull("display_class"), + syncClass = cursor.getStringOrNull("poll_class"), + notifyClass = cursor.getStringOrNull("notify_class"), + pushClass = cursor.getStringOrNull("push_class"), + lastUpdated = cursor.getLongOrNull("last_updated"), + unreadCount = cursor.getIntOrNull("unread_count"), + visibleLimit = cursor.getIntOrNull("visible_limit"), + status = cursor.getStringOrNull("status"), + flaggedCount = cursor.getIntOrNull("flagged_count"), + moreMessages = cursor.getStringOrNull("more_messages") + ) + } + } +} + +data class FolderEntry( + val id: Long?, + val name: String?, + val type: String?, + val serverId: String?, + val isLocalOnly: Int?, + val integrate: Int?, + val inTopGroup: Int?, + val displayClass: String?, + val syncClass: String?, + val notifyClass: String?, + val pushClass: String?, + val lastUpdated: Long?, + val unreadCount: Int?, + val visibleLimit: Int?, + val status: String?, + val flaggedCount: Int?, + val moreMessages: String? +) diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueHelpers.kt new file mode 100644 index 0000000..5af9132 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueHelpers.kt @@ -0,0 +1,77 @@ +package com.fsck.k9.storage.messages + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import app.k9mail.core.android.common.database.getLongOrNull +import app.k9mail.core.android.common.database.getStringOrNull +import app.k9mail.core.android.common.database.map + +fun SQLiteDatabase.createExtraValue( + name: String = "irrelevant", + text: String? = null, + number: Long? = null +): Long { + val values = ContentValues().apply { + put("name", name) + put("value_text", text) + put("value_integer", number) + } + + return insert("account_extra_values", null, values) +} + +fun SQLiteDatabase.readExtraValues(): List<ExtraValueEntry> { + val cursor = rawQuery("SELECT * FROM account_extra_values", null) + return cursor.use { + cursor.map { + ExtraValueEntry( + name = cursor.getStringOrNull("name"), + text = cursor.getStringOrNull("value_text"), + number = cursor.getLongOrNull("value_integer") + ) + } + } +} + +data class ExtraValueEntry( + val name: String?, + val text: String?, + val number: Long? +) + +fun SQLiteDatabase.createFolderExtraValue( + folderId: Long, + name: String = "irrelevant", + text: String? = null, + number: Long? = null +): Long { + val values = ContentValues().apply { + put("folder_id", folderId) + put("name", name) + put("value_text", text) + put("value_integer", number) + } + + return insert("folder_extra_values", null, values) +} + +fun SQLiteDatabase.readFolderExtraValues(): List<FolderExtraValueEntry> { + val cursor = rawQuery("SELECT * FROM folder_extra_values", null) + return cursor.use { + cursor.map { + FolderExtraValueEntry( + folderId = cursor.getLongOrNull("folder_id"), + name = cursor.getStringOrNull("name"), + text = cursor.getStringOrNull("value_text"), + number = cursor.getLongOrNull("value_integer") + ) + } + } +} + +data class FolderExtraValueEntry( + val folderId: Long?, + val name: String?, + val text: String?, + val number: Long? +) diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueStoreOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueStoreOperationsTest.kt new file mode 100644 index 0000000..f31c591 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/KeyValueStoreOperationsTest.kt @@ -0,0 +1,210 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test + +class KeyValueStoreOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val keyValueStoreOperations = KeyValueStoreOperations(lockableDatabase) + + @Test + fun `get extra string`() { + sqliteDatabase.createExtraValue(name = "test", text = "Wurstsalat") + + val result = keyValueStoreOperations.getExtraString("test") + + assertThat(result).isEqualTo("Wurstsalat") + } + + @Test + fun `get non-existent extra string`() { + val result = keyValueStoreOperations.getExtraString("test") + + assertThat(result).isNull() + } + + @Test + fun `create extra string`() { + keyValueStoreOperations.setExtraString("jmapState", "ABC42") + + val extraValues = sqliteDatabase.readExtraValues() + assertThat(extraValues).hasSize(1) + assertThat(extraValues.first()).isEqualTo( + ExtraValueEntry( + name = "jmapState", + text = "ABC42", + number = null + ) + ) + } + + @Test + fun `update extra string`() { + sqliteDatabase.createExtraValue(name = "jmapState", text = "XYZ23") + + keyValueStoreOperations.setExtraString("jmapState", "ABC42") + + val extraValues = sqliteDatabase.readExtraValues() + assertThat(extraValues).hasSize(1) + assertThat(extraValues.first()).isEqualTo( + ExtraValueEntry( + name = "jmapState", + text = "ABC42", + number = null + ) + ) + } + + @Test + fun `get extra number`() { + sqliteDatabase.createExtraValue(name = "test", number = 23) + + val result = keyValueStoreOperations.getExtraNumber("test") + + assertThat(result).isEqualTo(23) + } + + @Test + fun `get non-existent extra number`() { + val result = keyValueStoreOperations.getExtraNumber("test") + + assertThat(result).isNull() + } + + @Test + fun `create extra number`() { + keyValueStoreOperations.setExtraNumber("lastChanged", 123L) + + val extraValues = sqliteDatabase.readExtraValues() + assertThat(extraValues).hasSize(1) + assertThat(extraValues.first()).isEqualTo( + ExtraValueEntry( + name = "lastChanged", + text = null, + number = 123L + ) + ) + } + + @Test + fun `update extra number`() { + sqliteDatabase.createExtraValue(name = "lastChanged", number = 0L) + + keyValueStoreOperations.setExtraNumber("lastChanged", 42L) + + val extraValues = sqliteDatabase.readExtraValues() + assertThat(extraValues).hasSize(1) + assertThat(extraValues.first()).isEqualTo( + ExtraValueEntry( + name = "lastChanged", + text = null, + number = 42 + ) + ) + } + + @Test + fun `get extra folder string`() { + sqliteDatabase.createFolderExtraValue(folderId = 1, name = "test", text = "Wurstsalat") + + val result = keyValueStoreOperations.getFolderExtraString(folderId = 1, name = "test") + + assertThat(result).isEqualTo("Wurstsalat") + } + + @Test + fun `get non-existent extra folder string`() { + val result = keyValueStoreOperations.getFolderExtraString(folderId = 1, name = "test") + + assertThat(result).isNull() + } + + @Test + fun `create extra folder string`() { + keyValueStoreOperations.setFolderExtraString(folderId = 1, name = "imapUidValidity", value = "1") + + val folderExtraValues = sqliteDatabase.readFolderExtraValues() + assertThat(folderExtraValues).hasSize(1) + assertThat(folderExtraValues.first()).isEqualTo( + FolderExtraValueEntry( + folderId = 1, + name = "imapUidValidity", + text = "1", + number = null + ) + ) + } + + @Test + fun `update extra folder string`() { + sqliteDatabase.createFolderExtraValue(folderId = 1, name = "imapUidValidity", text = "23") + + keyValueStoreOperations.setFolderExtraString(folderId = 1, name = "imapUidValidity", value = "42") + + val folderExtraValues = sqliteDatabase.readFolderExtraValues() + assertThat(folderExtraValues).hasSize(1) + assertThat(folderExtraValues.first()).isEqualTo( + FolderExtraValueEntry( + folderId = 1, + name = "imapUidValidity", + text = "42", + number = null + ) + ) + } + + @Test + fun `get extra folder number`() { + sqliteDatabase.createFolderExtraValue(folderId = 1, name = "test", number = 23) + + val result = keyValueStoreOperations.getFolderExtraNumber(folderId = 1, name = "test") + + assertThat(result).isEqualTo(23) + } + + @Test + fun `get non-existent extra folder number`() { + val result = keyValueStoreOperations.getFolderExtraNumber(folderId = 1, name = "test") + + assertThat(result).isNull() + } + + @Test + fun `create extra folder number`() { + keyValueStoreOperations.setFolderExtraNumber(folderId = 1, name = "lastChanged", value = 123L) + + val folderExtraValues = sqliteDatabase.readFolderExtraValues() + assertThat(folderExtraValues).hasSize(1) + assertThat(folderExtraValues.first()).isEqualTo( + FolderExtraValueEntry( + folderId = 1, + name = "lastChanged", + text = null, + number = 123L + ) + ) + } + + @Test + fun `update extra folder number`() { + sqliteDatabase.createFolderExtraValue(folderId = 1, name = "lastChanged", number = 0L) + + keyValueStoreOperations.setFolderExtraNumber(folderId = 1, name = "lastChanged", value = 42L) + + val folderExtraValues = sqliteDatabase.readFolderExtraValues() + assertThat(folderExtraValues).hasSize(1) + assertThat(folderExtraValues.first()).isEqualTo( + FolderExtraValueEntry( + folderId = 1, + name = "lastChanged", + text = null, + number = 42 + ) + ) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt new file mode 100644 index 0000000..6ccbdbf --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt @@ -0,0 +1,213 @@ +package com.fsck.k9.storage.messages + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import app.k9mail.core.android.common.database.getIntOrNull +import app.k9mail.core.android.common.database.getLongOrNull +import app.k9mail.core.android.common.database.getStringOrNull +import app.k9mail.core.android.common.database.map +import com.fsck.k9.mailstore.DatabasePreviewType +import com.fsck.k9.mailstore.LockableDatabase +import com.fsck.k9.mailstore.MigrationsHelper +import com.fsck.k9.storage.K9SchemaDefinitionFactory +import java.io.File +import org.mockito.ArgumentMatchers +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock + +fun createLockableDatabaseMock(sqliteDatabase: SQLiteDatabase): LockableDatabase { + return mock { + on { execute(ArgumentMatchers.anyBoolean(), any<LockableDatabase.DbCallback<Any>>()) } doAnswer { stubbing -> + val callback: LockableDatabase.DbCallback<Any> = stubbing.getArgument(1) + callback.doDbWork(sqliteDatabase) + } + } +} + +fun createDatabase(): SQLiteDatabase { + val migrationsHelper = mock<MigrationsHelper>() + + val sqliteDatabase = SQLiteDatabase.create(null) + val schemaDefinitionFactory = K9SchemaDefinitionFactory() + val schemaDefinition = schemaDefinitionFactory.createSchemaDefinition(migrationsHelper) + + schemaDefinition.doDbUpgrade(sqliteDatabase) + + return sqliteDatabase +} + +fun SQLiteDatabase.createMessage( + folderId: Long, + deleted: Boolean = false, + uid: String? = null, + subject: String = "", + date: Long = 0L, + flags: String = "", + senderList: String = "", + toList: String = "", + ccList: String = "", + bccList: String = "", + replyToList: String = "", + attachmentCount: Int = 0, + internalDate: Long = 0L, + messageIdHeader: String? = null, + previewType: DatabasePreviewType = DatabasePreviewType.NONE, + preview: String = "", + mimeType: String = "text/plain", + normalizedSubjectHash: Long = 0L, + empty: Boolean = false, + read: Boolean = false, + flagged: Boolean = false, + answered: Boolean = false, + forwarded: Boolean = false, + messagePartId: Long = 0L, + encryptionType: String? = null, + newMessage: Boolean = false +): Long { + val values = ContentValues().apply { + put("deleted", if (deleted) 1 else 0) + put("folder_id", folderId) + put("uid", uid) + put("subject", subject) + put("date", date) + put("flags", flags) + put("sender_list", senderList) + put("to_list", toList) + put("cc_list", ccList) + put("bcc_list", bccList) + put("reply_to_list", replyToList) + put("attachment_count", attachmentCount) + put("internal_date", internalDate) + put("message_id", messageIdHeader) + put("preview_type", previewType.databaseValue) + put("preview", preview) + put("mime_type", mimeType) + put("normalized_subject_hash", normalizedSubjectHash) + put("empty", if (empty) 1 else 0) + put("read", if (read) 1 else 0) + put("flagged", if (flagged) 1 else 0) + put("answered", if (answered) 1 else 0) + put("forwarded", if (forwarded) 1 else 0) + put("message_part_id", messagePartId) + put("encryption_type", encryptionType) + put("new_message", if (newMessage) 1 else 0) + } + + return insert("messages", null, values) +} + +fun SQLiteDatabase.readMessages(): List<MessageEntry> { + val cursor = rawQuery("SELECT * FROM messages", null) + return cursor.use { + cursor.map { + MessageEntry( + id = cursor.getLongOrNull("id"), + deleted = cursor.getIntOrNull("deleted"), + folderId = cursor.getLongOrNull("folder_id"), + uid = cursor.getStringOrNull("uid"), + subject = cursor.getStringOrNull("subject"), + date = cursor.getLongOrNull("date"), + flags = cursor.getStringOrNull("flags"), + senderList = cursor.getStringOrNull("sender_list"), + toList = cursor.getStringOrNull("to_list"), + ccList = cursor.getStringOrNull("cc_list"), + bccList = cursor.getStringOrNull("bcc_list"), + replyToList = cursor.getStringOrNull("reply_to_list"), + attachmentCount = cursor.getIntOrNull("attachment_count"), + internalDate = cursor.getLongOrNull("internal_date"), + messageId = cursor.getStringOrNull("message_id"), + previewType = cursor.getStringOrNull("preview_type"), + preview = cursor.getStringOrNull("preview"), + mimeType = cursor.getStringOrNull("mime_type"), + normalizedSubjectHash = cursor.getLongOrNull("normalized_subject_hash"), + empty = cursor.getIntOrNull("empty"), + read = cursor.getIntOrNull("read"), + flagged = cursor.getIntOrNull("flagged"), + answered = cursor.getIntOrNull("answered"), + forwarded = cursor.getIntOrNull("forwarded"), + messagePartId = cursor.getLongOrNull("message_part_id"), + encryptionType = cursor.getStringOrNull("encryption_type"), + newMessage = cursor.getIntOrNull("new_message") + ) + } + } +} + +data class MessageEntry( + val id: Long?, + val deleted: Int?, + val folderId: Long?, + val uid: String?, + val subject: String?, + val date: Long?, + val flags: String?, + val senderList: String?, + val toList: String?, + val ccList: String?, + val bccList: String?, + val replyToList: String?, + val attachmentCount: Int?, + val internalDate: Long?, + val messageId: String?, + val previewType: String?, + val preview: String?, + val mimeType: String?, + val normalizedSubjectHash: Long?, + val empty: Int?, + val read: Int?, + val flagged: Int?, + val answered: Int?, + val forwarded: Int?, + val messagePartId: Long?, + val encryptionType: String?, + val newMessage: Int? +) + +fun SQLiteDatabase.createMessagePart( + type: Int = 0, + root: Int? = null, + parent: Int = -1, + seq: Int = 0, + mimeType: String = "text/plain", + decodedBodySize: Int = 0, + displayName: String? = null, + header: String? = null, + encoding: String = "7bit", + charset: String? = null, + dataLocation: Int = 0, + data: ByteArray? = null, + preamble: String? = null, + epilogue: String? = null, + boundary: String? = null, + contentId: String? = null, + serverExtra: String? = null, + directory: File? = null +): Long { + val values = ContentValues().apply { + put("type", type) + put("root", root) + put("parent", parent) + put("seq", seq) + put("mime_type", mimeType) + put("decoded_body_size", decodedBodySize) + put("display_name", displayName) + put("header", header?.toByteArray()) + put("encoding", encoding) + put("charset", charset) + put("data_location", dataLocation) + put("data", data) + put("preamble", preamble) + put("epilogue", epilogue) + put("boundary", boundary) + put("content_id", contentId) + put("server_extra", serverExtra) + } + + return insert("message_parts", null, values).also { messagePartId -> + if (dataLocation == DATA_LOCATION_ON_DISK) { + requireNotNull(directory) { "Argument 'directory' can't be null when 'dataLocation = 2'" } + File(directory, messagePartId.toString()).createNewFile() + } + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessagePartDatabaseHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessagePartDatabaseHelpers.kt new file mode 100644 index 0000000..19e915e --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessagePartDatabaseHelpers.kt @@ -0,0 +1,182 @@ +package com.fsck.k9.storage.messages + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import app.k9mail.core.android.common.database.getIntOrNull +import app.k9mail.core.android.common.database.getLongOrNull +import app.k9mail.core.android.common.database.getStringOrNull +import app.k9mail.core.android.common.database.map + +fun SQLiteDatabase.createMessagePart( + type: Int = MessagePartType.UNKNOWN, + root: Long? = null, + parent: Long = -1, + seq: Int = 0, + mimeType: String? = null, + decodedBodySize: Int? = null, + displayName: String? = null, + header: String? = null, + encoding: String? = null, + charset: String? = null, + dataLocation: Int = DataLocation.MISSING, + data: ByteArray? = null, + preamble: String? = null, + epilogue: String? = null, + boundary: String? = null, + contentId: String? = null, + serverExtra: String? = null +): Long { + val values = ContentValues().apply { + put("type", type) + put("root", root) + put("parent", parent) + put("seq", seq) + put("mime_type", mimeType) + put("decoded_body_size", decodedBodySize) + put("display_name", displayName) + put("header", header?.toByteArray()) + put("encoding", encoding) + put("charset", charset) + put("data_location", dataLocation) + put("data", data) + put("preamble", preamble) + put("epilogue", epilogue) + put("boundary", boundary) + put("content_id", contentId) + put("server_extra", serverExtra) + } + + return insert("message_parts", null, values) +} + +fun SQLiteDatabase.readMessageParts(): List<MessagePartEntry> { + return rawQuery("SELECT * FROM message_parts", null).use { cursor -> + cursor.map { + MessagePartEntry( + id = cursor.getLongOrNull("id"), + type = cursor.getIntOrNull("type"), + root = cursor.getLongOrNull("root"), + parent = cursor.getLongOrNull("parent"), + seq = cursor.getIntOrNull("seq"), + mimeType = cursor.getStringOrNull("mime_type"), + decodedBodySize = cursor.getIntOrNull("decoded_body_size"), + displayName = cursor.getStringOrNull("display_name"), + header = cursor.getBlobOrNull("header"), + encoding = cursor.getStringOrNull("encoding"), + charset = cursor.getStringOrNull("charset"), + dataLocation = cursor.getIntOrNull("data_location"), + data = cursor.getBlobOrNull("data"), + preamble = cursor.getStringOrNull("preamble"), + epilogue = cursor.getStringOrNull("epilogue"), + boundary = cursor.getStringOrNull("boundary"), + contentId = cursor.getStringOrNull("content_id"), + serverExtra = cursor.getStringOrNull("server_extra") + ) + } + } +} + +data class MessagePartEntry( + val id: Long?, + val type: Int?, + val root: Long?, + val parent: Long?, + val seq: Int?, + val mimeType: String?, + val decodedBodySize: Int?, + val displayName: String?, + val header: ByteArray?, + val encoding: String?, + val charset: String?, + val dataLocation: Int?, + val data: ByteArray?, + val preamble: String?, + val epilogue: String?, + val boundary: String?, + val contentId: String?, + val serverExtra: String? +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MessagePartEntry + + if (id != other.id) return false + if (type != other.type) return false + if (root != other.root) return false + if (parent != other.parent) return false + if (seq != other.seq) return false + if (mimeType != other.mimeType) return false + if (decodedBodySize != other.decodedBodySize) return false + if (displayName != other.displayName) return false + if (header != null) { + if (other.header == null) return false + if (!header.contentEquals(other.header)) return false + } else if (other.header != null) return false + if (encoding != other.encoding) return false + if (charset != other.charset) return false + if (dataLocation != other.dataLocation) return false + if (data != null) { + if (other.data == null) return false + if (!data.contentEquals(other.data)) return false + } else if (other.data != null) return false + if (preamble != other.preamble) return false + if (epilogue != other.epilogue) return false + if (boundary != other.boundary) return false + if (contentId != other.contentId) return false + if (serverExtra != other.serverExtra) return false + + return true + } + + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + (type ?: 0) + result = 31 * result + (root?.hashCode() ?: 0) + result = 31 * result + (parent?.hashCode() ?: 0) + result = 31 * result + (seq ?: 0) + result = 31 * result + (mimeType?.hashCode() ?: 0) + result = 31 * result + (decodedBodySize ?: 0) + result = 31 * result + (displayName?.hashCode() ?: 0) + result = 31 * result + (header?.contentHashCode() ?: 0) + result = 31 * result + (encoding?.hashCode() ?: 0) + result = 31 * result + (charset?.hashCode() ?: 0) + result = 31 * result + (dataLocation ?: 0) + result = 31 * result + (data?.contentHashCode() ?: 0) + result = 31 * result + (preamble?.hashCode() ?: 0) + result = 31 * result + (epilogue?.hashCode() ?: 0) + result = 31 * result + (boundary?.hashCode() ?: 0) + result = 31 * result + (contentId?.hashCode() ?: 0) + result = 31 * result + (serverExtra?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "MessagePartEntry(" + + "id=$id, " + + "type=$type, " + + "root=$root, " + + "parent=$parent, " + + "seq=$seq, " + + "mimeType=$mimeType, " + + "decodedBodySize=$decodedBodySize, " + + "displayName=$displayName, " + + "header=${header?.decodeToString()}, " + + "encoding=$encoding, " + + "charset=$charset, " + + "dataLocation=$dataLocation, " + + "data=${data?.decodeToString()}, " + + "preamble=$preamble, " + + "epilogue=$epilogue, " + + "boundary=$boundary, " + + "contentId=$contentId, " + + "serverExtra=$serverExtra)" + } +} + +private fun Cursor.getBlobOrNull(columnName: String): ByteArray? { + val columnIndex = getColumnIndex(columnName) + return if (isNull(columnIndex)) null else getBlob(columnIndex) +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/MoveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/MoveMessageOperationsTest.kt new file mode 100644 index 0000000..00dc9e4 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/MoveMessageOperationsTest.kt @@ -0,0 +1,216 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.startsWith +import com.fsck.k9.K9 +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test +import org.junit.Assert.fail as junitFail + +private const val SOURCE_FOLDER_ID = 3L +private const val DESTINATION_FOLDER_ID = 23L +private const val MESSAGE_ID_HEADER = "<00000000-0000-4000-0000-000000000000@domain.example>" + +class MoveMessageOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val moveMessageOperations = MoveMessageOperations(lockableDatabase, ThreadMessageOperations()) + + @Test + fun `move message not part of a thread`() { + val originalMessageId = sqliteDatabase.createMessage( + folderId = SOURCE_FOLDER_ID, + uid = "uid1", + subject = "Move me", + messageIdHeader = MESSAGE_ID_HEADER + ) + sqliteDatabase.createThread(messageId = originalMessageId) + val originalMessage = sqliteDatabase.readMessages().first() + + val destinationMessageId = moveMessageOperations.moveMessage( + messageId = originalMessageId, + destinationFolderId = DESTINATION_FOLDER_ID + ) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(2) + + val sourceMessage = messages.first { it.id == originalMessageId } + assertThat(sourceMessage.folderId).isEqualTo(SOURCE_FOLDER_ID) + assertThat(sourceMessage.uid).isEqualTo("uid1") + assertThat(sourceMessage.messageId).isEqualTo(MESSAGE_ID_HEADER) + assertPlaceholderEntry(sourceMessage) + + val destinationMessage = messages.first { it.id == destinationMessageId } + assertThat(destinationMessage.uid).isNotNull().startsWith(K9.LOCAL_UID_PREFIX) + assertThat(destinationMessage).isEqualTo( + originalMessage.copy( + id = destinationMessageId, + folderId = DESTINATION_FOLDER_ID, + uid = destinationMessage.uid, + deleted = 0, + empty = 0 + ) + ) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(2) + + val originalMessageThread = threads.first { it.messageId == originalMessageId } + assertThat(originalMessageThread.id).isEqualTo(originalMessageThread.root) + assertThat(originalMessageThread.parent).isNull() + + val destinationMessageThread = threads.first { it.messageId == destinationMessageId } + assertThat(destinationMessageThread.id).isEqualTo(destinationMessageThread.root) + assertThat(destinationMessageThread.parent).isNull() + } + + @Test + fun `move message when destination has empty message entry`() { + val originalMessageId = sqliteDatabase.createMessage( + folderId = SOURCE_FOLDER_ID, + uid = "uid1", + subject = "Move me", + messageIdHeader = MESSAGE_ID_HEADER, + read = false + ) + sqliteDatabase.createThread(messageId = originalMessageId) + val originalMessage = sqliteDatabase.readMessages().first() + val placeholderMessageId = sqliteDatabase.createMessage( + empty = true, + folderId = DESTINATION_FOLDER_ID, + messageIdHeader = MESSAGE_ID_HEADER, + uid = "" + ) + val placeholderThreadId = sqliteDatabase.createThread(messageId = placeholderMessageId) + val childMessageId = sqliteDatabase.createMessage( + folderId = DESTINATION_FOLDER_ID, + messageIdHeader = "<msg02@domain.example>", + uid = "uid2" + ) + sqliteDatabase.createThread( + messageId = childMessageId, + root = placeholderThreadId, + parent = placeholderThreadId + ) + + val destinationMessageId = moveMessageOperations.moveMessage( + messageId = originalMessageId, + destinationFolderId = DESTINATION_FOLDER_ID + ) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(3) + + val sourceMessage = messages.first { it.id == originalMessageId } + assertThat(sourceMessage.folderId).isEqualTo(SOURCE_FOLDER_ID) + assertThat(sourceMessage.uid).isEqualTo("uid1") + assertThat(sourceMessage.messageId).isEqualTo(MESSAGE_ID_HEADER) + assertPlaceholderEntry(sourceMessage) + + val destinationMessage = messages.first { it.id == destinationMessageId } + assertThat(destinationMessage.uid).isNotNull().startsWith(K9.LOCAL_UID_PREFIX) + assertThat(destinationMessage).isEqualTo( + originalMessage.copy( + id = destinationMessageId, + folderId = DESTINATION_FOLDER_ID, + uid = destinationMessage.uid, + deleted = 0, + empty = 0 + ) + ) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(3) + + val originalMessageThread = threads.first { it.messageId == originalMessageId } + assertThat(originalMessageThread.id).isEqualTo(originalMessageThread.root) + assertThat(originalMessageThread.parent).isNull() + + val destinationMessageThread = threads.first { it.messageId == destinationMessageId } + assertThat(destinationMessageThread.id).isEqualTo(destinationMessageThread.root) + assertThat(destinationMessageThread.parent).isNull() + + val childMessageThread = threads.first { it.messageId == childMessageId } + assertThat(childMessageThread.root).isEqualTo(destinationMessageThread.id) + assertThat(childMessageThread.parent).isEqualTo(destinationMessageThread.id) + } + + @Test + fun `move message not containing a message-id header`() { + val originalMessageId = sqliteDatabase.createMessage( + folderId = SOURCE_FOLDER_ID, + uid = "uid1", + subject = "Move me", + messageIdHeader = null + ) + sqliteDatabase.createThread(messageId = originalMessageId) + val originalMessage = sqliteDatabase.readMessages().first() + + val destinationMessageId = moveMessageOperations.moveMessage( + messageId = originalMessageId, + destinationFolderId = DESTINATION_FOLDER_ID + ) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(2) + + val sourceMessage = messages.first { it.id == originalMessageId } + assertThat(sourceMessage.folderId).isEqualTo(SOURCE_FOLDER_ID) + assertThat(sourceMessage.uid).isEqualTo("uid1") + assertPlaceholderEntry(sourceMessage) + + val destinationMessage = messages.first { it.id == destinationMessageId } + assertThat(destinationMessage.uid).isNotNull().startsWith(K9.LOCAL_UID_PREFIX) + assertThat(destinationMessage).isEqualTo( + originalMessage.copy( + id = destinationMessageId, + folderId = DESTINATION_FOLDER_ID, + uid = destinationMessage.uid, + deleted = 0, + empty = 0 + ) + ) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(2) + + val originalMessageThread = threads.first { it.messageId == originalMessageId } + assertThat(originalMessageThread.id).isEqualTo(originalMessageThread.root) + assertThat(originalMessageThread.parent).isNull() + + val destinationMessageThread = threads.first { it.messageId == destinationMessageId } + assertThat(destinationMessageThread.id).isEqualTo(destinationMessageThread.root) + assertThat(destinationMessageThread.parent).isNull() + } + + private fun assertPlaceholderEntry(message: MessageEntry) { + assertThat(message.deleted).isEqualTo(1) + assertThat(message.empty).isEqualTo(0) + assertThat(message.read).isEqualTo(1) + assertThat(message.date).isNull() + assertThat(message.flags).isNull() + assertThat(message.senderList).isNull() + assertThat(message.toList).isNull() + assertThat(message.ccList).isNull() + assertThat(message.bccList).isNull() + assertThat(message.replyToList).isNull() + assertThat(message.attachmentCount).isNull() + assertThat(message.internalDate).isNull() + assertThat(message.previewType).isEqualTo("none") + assertThat(message.preview).isNull() + assertThat(message.mimeType).isNull() + assertThat(message.normalizedSubjectHash).isNull() + assertThat(message.flagged).isNull() + assertThat(message.answered).isNull() + assertThat(message.forwarded).isNull() + assertThat(message.messagePartId).isNull() + assertThat(message.encryptionType).isNull() + } + + private fun fail(message: String): Nothing = junitFail(message) as Nothing +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt new file mode 100644 index 0000000..d4eb74f --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt @@ -0,0 +1,522 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFailure +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import com.fsck.k9.Account.FolderMode +import com.fsck.k9.mail.FolderClass +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mailstore.FolderNotFoundException +import com.fsck.k9.mailstore.MoreMessages +import com.fsck.k9.mailstore.toDatabaseFolderType +import com.fsck.k9.search.LocalSearch +import com.fsck.k9.search.SearchSpecification +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test + +class RetrieveFolderOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val retrieveFolderOperations = RetrieveFolderOperations(lockableDatabase) + + @Test + fun `get folder`() { + val folderId = sqliteDatabase.createFolder( + name = "Folder Name", + type = "inbox", + serverId = "uid", + isLocalOnly = false, + integrate = true, + inTopGroup = true, + displayClass = "FIRST_CLASS", + syncClass = "FIRST_CLASS", + notifyClass = "NO_CLASS", + pushClass = "NO_CLASS" + ) + + val result = retrieveFolderOperations.getFolder(folderId) { folder -> + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.name).isEqualTo("Folder Name") + assertThat(folder.type).isEqualTo(FolderType.INBOX) + assertThat(folder.serverId).isEqualTo("uid") + assertThat(folder.isLocalOnly).isEqualTo(false) + assertThat(folder.isIntegrate).isEqualTo(true) + assertThat(folder.isInTopGroup).isEqualTo(true) + assertThat(folder.displayClass).isEqualTo(FolderClass.FIRST_CLASS) + assertThat(folder.syncClass).isEqualTo(FolderClass.FIRST_CLASS) + assertThat(folder.notifyClass).isEqualTo(FolderClass.NO_CLASS) + assertThat(folder.pushClass).isEqualTo(FolderClass.NO_CLASS) + true + } + + assertThat(result).isNotNull().isTrue() + } + + @Test + fun `get local folder`() { + val name = "Local outbox" + val folderId = sqliteDatabase.createFolder( + name = name, + type = FolderType.OUTBOX.toDatabaseFolderType(), + serverId = null, + isLocalOnly = true, + integrate = true, + inTopGroup = true, + displayClass = FolderClass.FIRST_CLASS.name, + syncClass = null, + notifyClass = null, + pushClass = null + ) + + val result = retrieveFolderOperations.getFolder(folderId) { folder -> + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.name).isEqualTo(name) + assertThat(folder.type).isEqualTo(FolderType.OUTBOX) + assertThat(folder.serverId).isNull() + assertThat(folder.isLocalOnly).isEqualTo(true) + assertThat(folder.isIntegrate).isEqualTo(true) + assertThat(folder.isInTopGroup).isEqualTo(true) + assertThat(folder.displayClass).isEqualTo(FolderClass.FIRST_CLASS) + assertThat(folder.syncClass).isEqualTo(FolderClass.INHERITED) + assertThat(folder.notifyClass).isEqualTo(FolderClass.INHERITED) + assertThat(folder.pushClass).isEqualTo(FolderClass.SECOND_CLASS) + true + } + + assertThat(result).isNotNull().isTrue() + } + + @Test + fun `get non-existent folder should return null`() { + val result = retrieveFolderOperations.getFolder(1) { "failed" } + + assertThat(result).isNull() + } + + @Test + fun `get folder by server ID`() { + val folderId = sqliteDatabase.createFolder( + name = "Folder Name", + type = "inbox", + serverId = "folder1", + isLocalOnly = false, + integrate = true, + inTopGroup = true, + displayClass = "FIRST_CLASS", + syncClass = "FIRST_CLASS", + notifyClass = "NO_CLASS", + pushClass = "NO_CLASS" + ) + + val result = retrieveFolderOperations.getFolder("folder1") { folder -> + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.name).isEqualTo("Folder Name") + assertThat(folder.type).isEqualTo(FolderType.INBOX) + assertThat(folder.serverId).isEqualTo("folder1") + assertThat(folder.isLocalOnly).isEqualTo(false) + assertThat(folder.isIntegrate).isEqualTo(true) + assertThat(folder.isInTopGroup).isEqualTo(true) + assertThat(folder.displayClass).isEqualTo(FolderClass.FIRST_CLASS) + assertThat(folder.syncClass).isEqualTo(FolderClass.FIRST_CLASS) + assertThat(folder.notifyClass).isEqualTo(FolderClass.NO_CLASS) + assertThat(folder.pushClass).isEqualTo(FolderClass.NO_CLASS) + true + } + + assertThat(result).isNotNull().isTrue() + } + + @Test + fun `get non-existent folder by server ID should return null`() { + val result = retrieveFolderOperations.getFolder("folder_id") { "failed" } + + assertThat(result).isNull() + } + + @Test + fun `get folders should return all fields`() { + val folderId = sqliteDatabase.createFolder( + name = "Folder Name", + type = "inbox", + serverId = "uid", + isLocalOnly = false, + integrate = true, + inTopGroup = true, + displayClass = "FIRST_CLASS", + syncClass = "FIRST_CLASS", + notifyClass = "NO_CLASS", + pushClass = "NO_CLASS" + ) + + val result = retrieveFolderOperations.getFolders { folder -> + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.name).isEqualTo("Folder Name") + assertThat(folder.type).isEqualTo(FolderType.INBOX) + assertThat(folder.serverId).isEqualTo("uid") + assertThat(folder.isLocalOnly).isEqualTo(false) + assertThat(folder.isIntegrate).isEqualTo(true) + assertThat(folder.isInTopGroup).isEqualTo(true) + assertThat(folder.displayClass).isEqualTo(FolderClass.FIRST_CLASS) + assertThat(folder.syncClass).isEqualTo(FolderClass.FIRST_CLASS) + assertThat(folder.notifyClass).isEqualTo(FolderClass.NO_CLASS) + assertThat(folder.pushClass).isEqualTo(FolderClass.NO_CLASS) + true + } + + assertThat(result).isEqualTo(listOf(true)) + } + + @Test + fun `get folders with excludeLocalOnly should only return remote folders`() { + val (folderId1, _, folderId3) = listOf( + sqliteDatabase.createFolder(name = "Folder 1", isLocalOnly = false), + sqliteDatabase.createFolder(name = "Folder 2", isLocalOnly = true), + sqliteDatabase.createFolder(name = "Folder 3", isLocalOnly = false) + ) + + val result = retrieveFolderOperations.getFolders(excludeLocalOnly = true) { folder -> + folder.id to folder.name + } + + assertThat(result).isEqualTo( + listOf( + folderId1 to "Folder 1", + folderId3 to "Folder 3" + ) + ) + } + + @Test + fun `get folders with empty store should return empty list`() { + val result = retrieveFolderOperations.getFolders { "failed" } + + assertThat(result).isEmpty() + } + + @Test + fun `get first class display folders`() { + val (folderId1, folderId2, _) = listOf( + sqliteDatabase.createFolder(name = "Folder 1", displayClass = "FIRST_CLASS"), + sqliteDatabase.createFolder(name = "Folder 2", displayClass = "SECOND_CLASS"), + sqliteDatabase.createFolder(name = "Folder 3", displayClass = "NO_CLASS") + ) + + val result = retrieveFolderOperations.getDisplayFolders( + displayMode = FolderMode.FIRST_CLASS, + outboxFolderId = folderId2 + ) { folder -> + folder.id to folder.name + } + + assertThat(result).isEqualTo(listOf(folderId1 to "Folder 1")) + } + + @Test + fun `get everything but second class display folders`() { + val (folderId1, _, folderId3) = listOf( + sqliteDatabase.createFolder(name = "Folder 1", displayClass = "FIRST_CLASS"), + sqliteDatabase.createFolder(name = "Folder 2", displayClass = "SECOND_CLASS"), + sqliteDatabase.createFolder(name = "Folder 3", displayClass = "NO_CLASS") + ) + + val result = retrieveFolderOperations.getDisplayFolders( + displayMode = FolderMode.NOT_SECOND_CLASS, + outboxFolderId = folderId1 + ) { folder -> + folder.id to folder.name + } + + assertThat(result).isEqualTo( + listOf( + folderId1 to "Folder 1", + folderId3 to "Folder 3" + ) + ) + } + + @Test + fun `get first and second class display folders`() { + val (folderId1, folderId2, _) = listOf( + sqliteDatabase.createFolder(name = "Folder 1", displayClass = "FIRST_CLASS"), + sqliteDatabase.createFolder(name = "Folder 2", displayClass = "SECOND_CLASS"), + sqliteDatabase.createFolder(name = "Folder 3", displayClass = "NO_CLASS") + ) + + val result = retrieveFolderOperations.getDisplayFolders( + displayMode = FolderMode.FIRST_AND_SECOND_CLASS, + outboxFolderId = folderId1 + ) { folder -> + folder.id to folder.name + } + + assertThat(result).isEqualTo( + listOf( + folderId1 to "Folder 1", + folderId2 to "Folder 2" + ) + ) + } + + @Test + fun `get display folders with message count`() { + val (folderId1, folderId2, folderId3, folderId4) = listOf( + sqliteDatabase.createFolder(name = "Folder 1", displayClass = "FIRST_CLASS"), + sqliteDatabase.createFolder(name = "Folder 2", displayClass = "SECOND_CLASS"), + sqliteDatabase.createFolder(name = "Folder 3", displayClass = "FIRST_CLASS"), + sqliteDatabase.createFolder(name = "Folder 4", displayClass = "NO_CLASS") + ) + sqliteDatabase.createMessage(uid = "msg1", folderId = folderId1, read = true) + sqliteDatabase.createMessage(uid = "msg2", folderId = folderId2, read = true) + sqliteDatabase.createMessage(uid = "msg3", folderId = folderId3, read = true) + sqliteDatabase.createMessage(uid = "msg4", folderId = folderId3, read = false) + sqliteDatabase.createMessage(uid = "msg5", folderId = folderId3, read = false) + + val result = retrieveFolderOperations.getDisplayFolders( + displayMode = FolderMode.ALL, + outboxFolderId = folderId2 + ) { folder -> + Triple(folder.id, folder.name, folder.unreadMessageCount) + } + + assertThat(result).hasSize(4) + assertThat(result.toSet()).isEqualTo( + setOf( + Triple(folderId1, "Folder 1", 0), + Triple(folderId2, "Folder 2", 1), + Triple(folderId3, "Folder 3", 2), + Triple(folderId4, "Folder 4", 0) + ) + ) + } + + @Test + fun `get folder id`() { + val (_, folderId2) = listOf( + sqliteDatabase.createFolder(serverId = "folder1"), + sqliteDatabase.createFolder(serverId = "folder2") + ) + + val result = retrieveFolderOperations.getFolderId(folderServerId = "folder2") + + assertThat(result).isEqualTo(folderId2) + } + + @Test + fun `get folder id should return null if no folder was found`() { + val result = retrieveFolderOperations.getFolderId(folderServerId = "folderId") + + assertThat(result).isNull() + } + + @Test + fun `get folder server id`() { + val (_, folderId2) = listOf( + sqliteDatabase.createFolder(serverId = "folder1"), + sqliteDatabase.createFolder(serverId = "folder2") + ) + + val result = retrieveFolderOperations.getFolderServerId(folderId2) + + assertThat(result).isEqualTo("folder2") + } + + @Test + fun `get folder server id should return null if no folder was found`() { + val result = retrieveFolderOperations.getFolderServerId(folderId = 1) + + assertThat(result).isNull() + } + + @Test + fun `get message count from empty folder`() { + val folderId = sqliteDatabase.createFolder() + + val result = retrieveFolderOperations.getMessageCount(folderId) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get message count from non-existent folder`() { + val result = retrieveFolderOperations.getMessageCount(23) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get message count from non-empty folder`() { + val folderId = sqliteDatabase.createFolder() + sqliteDatabase.createMessage(folderId = folderId) + sqliteDatabase.createMessage(folderId = folderId) + + val result = retrieveFolderOperations.getMessageCount(folderId) + + assertThat(result).isEqualTo(2) + } + + @Test + fun `get unread message count from empty folder`() { + val folderId = sqliteDatabase.createFolder() + + val result = retrieveFolderOperations.getUnreadMessageCount(folderId) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get unread message count from non-existent folder`() { + val result = retrieveFolderOperations.getUnreadMessageCount(23) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get unread message count from non-empty folder`() { + val folderId = sqliteDatabase.createFolder() + sqliteDatabase.createMessage(folderId = folderId, read = false) + sqliteDatabase.createMessage(folderId = folderId, read = false) + sqliteDatabase.createMessage(folderId = folderId, read = true) + + val result = retrieveFolderOperations.getUnreadMessageCount(folderId) + + assertThat(result).isEqualTo(2) + } + + @Test + fun `get unread message count with condition from empty folder`() { + sqliteDatabase.createFolder(integrate = true) + + val result = retrieveFolderOperations.getUnreadMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get unread message count with condition from non-existent folder`() { + val result = retrieveFolderOperations.getUnreadMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get unread message count with condition from non-empty folder`() { + val folderId1 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId1, read = false) + sqliteDatabase.createMessage(folderId = folderId1, read = false) + sqliteDatabase.createMessage(folderId = folderId1, read = true) + val folderId2 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId2, read = false) + sqliteDatabase.createMessage(folderId = folderId2, read = true) + val folderId3 = sqliteDatabase.createFolder(integrate = false) + sqliteDatabase.createMessage(folderId = folderId3, read = false) + + val result = retrieveFolderOperations.getUnreadMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(3) + } + + @Test + fun `get unread message count without condition`() { + val folderId1 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId1, read = false) + val folderId2 = sqliteDatabase.createFolder(integrate = false) + sqliteDatabase.createMessage(folderId = folderId2, read = false) + sqliteDatabase.createMessage(folderId = folderId2, read = true) + + val result = retrieveFolderOperations.getUnreadMessageCount(conditions = null) + + assertThat(result).isEqualTo(2) + } + + @Test + fun `get starred message count with condition from empty folder`() { + sqliteDatabase.createFolder(integrate = true) + + val result = retrieveFolderOperations.getStarredMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get starred message count with condition from non-existent folder`() { + val result = retrieveFolderOperations.getStarredMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get starred message count with condition from non-empty folder`() { + val folderId1 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId1, flagged = false) + sqliteDatabase.createMessage(folderId = folderId1, flagged = true) + val folderId2 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId2, flagged = true) + sqliteDatabase.createMessage(folderId = folderId2, flagged = true) + sqliteDatabase.createMessage(folderId = folderId2, flagged = false) + val folderId3 = sqliteDatabase.createFolder(integrate = false) + sqliteDatabase.createMessage(folderId = folderId3, flagged = true) + + val result = retrieveFolderOperations.getStarredMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(3) + } + + @Test + fun `get starred message count without condition`() { + val folderId1 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId1, flagged = true) + val folderId2 = sqliteDatabase.createFolder(integrate = false) + sqliteDatabase.createMessage(folderId = folderId2, flagged = true) + sqliteDatabase.createMessage(folderId = folderId2, flagged = false) + + val result = retrieveFolderOperations.getStarredMessageCount(conditions = null) + + assertThat(result).isEqualTo(2) + } + + @Test + fun `get 'more messages' value from non-existent folder`() { + assertThat { + retrieveFolderOperations.hasMoreMessages(23) + }.isFailure() + .isInstanceOf(FolderNotFoundException::class) + .transform { it.folderId }.isEqualTo(23) + } + + @Test + fun `get 'more messages' value from folder with value 'unknown'`() { + val folderId = sqliteDatabase.createFolder(moreMessages = "unknown") + + val result = retrieveFolderOperations.hasMoreMessages(folderId) + + assertThat(result).isEqualTo(MoreMessages.UNKNOWN) + } + + @Test + fun `get 'more messages' value from folder with value 'false'`() { + val folderId = sqliteDatabase.createFolder(moreMessages = "false") + + val result = retrieveFolderOperations.hasMoreMessages(folderId) + + assertThat(result).isEqualTo(MoreMessages.FALSE) + } + + @Test + fun `get 'more messages' value from folder with value 'true'`() { + val folderId = sqliteDatabase.createFolder(moreMessages = "true") + + val result = retrieveFolderOperations.hasMoreMessages(folderId) + + assertThat(result).isEqualTo(MoreMessages.TRUE) + } + + private val unifiedInboxConditions = LocalSearch().apply { + and(SearchSpecification.SearchField.INTEGRATE, "1", SearchSpecification.Attribute.EQUALS) + }.conditions +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt new file mode 100644 index 0000000..c009d68 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageListOperationsTest.kt @@ -0,0 +1,491 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.fsck.k9.mail.Address +import com.fsck.k9.mailstore.DatabasePreviewType +import com.fsck.k9.mailstore.MessageMapper +import com.fsck.k9.message.extractors.PreviewResult.PreviewType +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test + +class RetrieveMessageListOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val retrieveMessageListOperations = RetrieveMessageListOperations(lockableDatabase) + + @Test + fun `getMessages() on empty folder`() { + val folderId = sqliteDatabase.createFolder() + + val result = getMessagesFromFolder(folderId) { "unexpected" } + + assertThat(result).isEmpty() + } + + @Test + fun `getMessages() with only a deleted message`() { + val folderId = sqliteDatabase.createFolder() + val messageId = sqliteDatabase.createMessage(folderId, uid = "uid1", deleted = true) + sqliteDatabase.createThread(messageId) + + val result = getMessagesFromFolder(folderId) { "unexpected" } + + assertThat(result).isEmpty() + } + + @Test + fun `getMessages() with single message`() { + val folderId = sqliteDatabase.createFolder() + val messageId = sqliteDatabase.createMessage( + folderId, + uid = "uid1", + subject = "subject", + date = 123L, + senderList = Address.pack(Address.parse("from@domain.example")), + toList = Address.pack(Address.parse("to@domain.example")), + ccList = Address.pack(Address.parse("cc1@domain.example, cc2@domain.example")), + attachmentCount = 1, + internalDate = 456L, + previewType = DatabasePreviewType.TEXT, + preview = "preview", + read = true, + flagged = true, + answered = true, + forwarded = true + ) + val threadId = sqliteDatabase.createThread(messageId) + + val result = getMessagesFromFolder(folderId) { message -> + assertThat(message.id).isEqualTo(messageId) + assertThat(message.messageServerId).isEqualTo("uid1") + assertThat(message.folderId).isEqualTo(folderId) + assertThat(message.fromAddresses).containsExactly(Address("from@domain.example")) + assertThat(message.toAddresses).containsExactly(Address("to@domain.example")) + assertThat(message.ccAddresses).containsExactly(Address("cc1@domain.example"), Address("cc2@domain.example")) + assertThat(message.messageDate).isEqualTo(123L) + assertThat(message.internalDate).isEqualTo(456L) + assertThat(message.subject).isEqualTo("subject") + assertThat(message.preview.previewType).isEqualTo(PreviewType.TEXT) + assertThat(message.preview.previewText).isEqualTo("preview") + assertThat(message.isRead).isTrue() + assertThat(message.isStarred).isTrue() + assertThat(message.isAnswered).isTrue() + assertThat(message.isForwarded).isTrue() + assertThat(message.hasAttachments).isTrue() + assertThat(message.threadRoot).isEqualTo(threadId) + assertThat(message.threadCount).isEqualTo(0) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getMessages() with folder containing an empty message`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, empty = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> message.id } + + assertThat(result).containsExactly(messageId2) + } + + @Test + fun `getMessages() with folder containing a deleted message`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, deleted = true) + sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2) + + val result = getThreadedMessagesFromFolder(folderId) { message -> message.id } + + assertThat(result).containsExactly(messageId2) + } + + @Test + fun `getMessages() selecting only unread messages`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = false) + sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = true) + sqliteDatabase.createThread(messageId2) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3", read = false) + sqliteDatabase.createThread(messageId3) + + val result = retrieveMessageListOperations.getThreadedMessages( + selection = "folder_id = ? AND read = 0", + selectionArgs = arrayOf(folderId.toString()), + sortOrder = "date DESC, id DESC" + ) { message -> + message.id + } + + assertThat(result).containsExactly(messageId3, messageId1) + } + + @Test + fun `getThreadedMessages() on empty folder`() { + val folderId = sqliteDatabase.createFolder() + + val result = getThreadedMessagesFromFolder(folderId) { "unexpected" } + + assertThat(result).isEmpty() + } + + @Test + fun `getThreadedMessages() with only a deleted message`() { + val folderId = sqliteDatabase.createFolder() + val messageId = sqliteDatabase.createMessage(folderId, uid = "uid1", deleted = true) + sqliteDatabase.createThread(messageId) + + val result = getThreadedMessagesFromFolder(folderId) { "unexpected" } + + assertThat(result).isEmpty() + } + + @Test + fun `getThreadedMessages() with single message`() { + val folderId = sqliteDatabase.createFolder() + val messageId = sqliteDatabase.createMessage( + folderId, + uid = "uid1", + subject = "subject", + date = 123L, + senderList = Address.pack(Address.parse("from@domain.example")), + toList = Address.pack(Address.parse("to@domain.example")), + ccList = Address.pack(Address.parse("cc1@domain.example, cc2@domain.example")), + attachmentCount = 1, + internalDate = 456L, + previewType = DatabasePreviewType.TEXT, + preview = "preview", + read = true, + flagged = true, + answered = true, + forwarded = true + ) + val threadId = sqliteDatabase.createThread(messageId) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.id).isEqualTo(messageId) + assertThat(message.messageServerId).isEqualTo("uid1") + assertThat(message.folderId).isEqualTo(folderId) + assertThat(message.fromAddresses).containsExactly(Address("from@domain.example")) + assertThat(message.toAddresses).containsExactly(Address("to@domain.example")) + assertThat(message.ccAddresses).containsExactly(Address("cc1@domain.example"), Address("cc2@domain.example")) + assertThat(message.messageDate).isEqualTo(123L) + assertThat(message.internalDate).isEqualTo(456L) + assertThat(message.subject).isEqualTo("subject") + assertThat(message.preview.previewType).isEqualTo(PreviewType.TEXT) + assertThat(message.preview.previewText).isEqualTo("preview") + assertThat(message.isRead).isTrue() + assertThat(message.isStarred).isTrue() + assertThat(message.isAnswered).isTrue() + assertThat(message.isForwarded).isTrue() + assertThat(message.hasAttachments).isTrue() + assertThat(message.threadRoot).isEqualTo(threadId) + assertThat(message.threadCount).isEqualTo(1) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() with thread containing an empty message`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, empty = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.id).isEqualTo(messageId2) + assertThat(message.messageServerId).isEqualTo("uid2") + assertThat(message.threadRoot).isEqualTo(threadId1) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return latest message in thread`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage( + folderId, + uid = "uid1", + date = 1000L, + internalDate = 1001L + ) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage( + folderId, + uid = "uid2", + date = 2000L, + internalDate = 2001L + ) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.id).isEqualTo(messageId2) + assertThat(message.messageDate).isEqualTo(2000L) + assertThat(message.internalDate).isEqualTo(2001L) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'unread' when at least one message in thread is marked as unread`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = false) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isRead).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'read' when all messages in thread are marked as read`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", read = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", read = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isRead).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'starred' when at least one message in thread is marked as starred`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", flagged = false) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", flagged = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isStarred).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'not starred' when all messages in thread are not marked as starred`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", flagged = false) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", flagged = false) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isStarred).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'not answered' when not all messages in thread are marked as answered`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", answered = false) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", answered = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isAnswered).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'answered' when all messages in thread are marked as answered`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", answered = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", answered = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isAnswered).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'not forwarded' when not all messages in thread are marked as forwarded`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", forwarded = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", forwarded = false) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isForwarded).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'forwarded' when all messages in thread are marked as forwarded`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", forwarded = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", forwarded = true) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.isForwarded).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'has attachment' when at least one message in thread contains an attachment`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", attachmentCount = 1) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", attachmentCount = 0) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.hasAttachments).isTrue() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should return 'has no attachment' when no message in thread contains an attachment`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", attachmentCount = 0) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2", attachmentCount = 0) + sqliteDatabase.createThread(messageId2, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.hasAttachments).isFalse() + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() with 3 messages in thread`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1") + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3") + sqliteDatabase.createThread(messageId3, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.threadCount).isEqualTo(3) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThreadedMessages() should not include empty messages in thread count`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1", empty = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3") + sqliteDatabase.createThread(messageId3, root = threadId1) + + val result = getThreadedMessagesFromFolder(folderId) { message -> + assertThat(message.threadCount).isEqualTo(2) + "OK" + } + + assertThat(result).containsExactly("OK") + } + + @Test + fun `getThread() with empty message as thread root`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, empty = true) + val threadId1 = sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + sqliteDatabase.createThread(messageId2, root = threadId1) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3") + sqliteDatabase.createThread(messageId3, root = threadId1) + + val result = retrieveMessageListOperations.getThread(threadId = threadId1, sortOrder = "date DESC") { it.id } + + assertThat(result).containsExactly(messageId2, messageId3) + } + + @Test + fun `getThread() should only return messages in thread`() { + val folderId = sqliteDatabase.createFolder() + val messageId1 = sqliteDatabase.createMessage(folderId, uid = "uid1") + sqliteDatabase.createThread(messageId1) + val messageId2 = sqliteDatabase.createMessage(folderId, uid = "uid2") + val threadId2 = sqliteDatabase.createThread(messageId2) + val messageId3 = sqliteDatabase.createMessage(folderId, uid = "uid3") + sqliteDatabase.createThread(messageId3, root = threadId2) + + val result = retrieveMessageListOperations.getThread(threadId = threadId2, sortOrder = "date DESC") { it.id } + + assertThat(result).containsExactly(messageId2, messageId3) + } + + private fun <T> getMessagesFromFolder(folderId: Long, mapper: MessageMapper<T?>): List<T> { + return retrieveMessageListOperations.getMessages( + selection = "folder_id = ?", + selectionArgs = arrayOf(folderId.toString()), + sortOrder = "date DESC, id DESC", + mapper + ) + } + + private fun <T> getThreadedMessagesFromFolder(folderId: Long, mapper: MessageMapper<T?>): List<T> { + return retrieveMessageListOperations.getThreadedMessages( + selection = "folder_id = ?", + selectionArgs = arrayOf(folderId.toString()), + sortOrder = "date DESC, id DESC", + mapper + ) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt new file mode 100644 index 0000000..e933fa6 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt @@ -0,0 +1,222 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Header +import com.fsck.k9.mail.crlf +import com.fsck.k9.storage.RobolectricTest +import java.util.Date +import org.junit.Test + +class RetrieveMessageOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val retrieveMessageOperations = RetrieveMessageOperations(lockableDatabase) + + @Test + fun `get message server id of non-existent message`() { + val messageServerId = retrieveMessageOperations.getMessageServerId(42) + + assertThat(messageServerId).isNull() + } + + @Test + fun `get message server id`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1") + val messageId = sqliteDatabase.createMessage(folderId = 1, uid = "uid2") + + val messageServerId = retrieveMessageOperations.getMessageServerId(messageId) + + assertThat(messageServerId).isEqualTo("uid2") + } + + @Test + fun `get message server ids`() { + val messageId1 = sqliteDatabase.createMessage(folderId = 1, uid = "uid1") + val messageId2 = sqliteDatabase.createMessage(folderId = 1, uid = "uid2") + val messageId3 = sqliteDatabase.createMessage(folderId = 1, uid = "uid3") + val messageId4 = sqliteDatabase.createMessage(folderId = 1, uid = "uid4") + sqliteDatabase.createMessage(folderId = 1, uid = "uid5") + val messageIds = listOf(messageId1, messageId2, messageId3, messageId4) + + val databaseIdToServerIdMapping = retrieveMessageOperations.getMessageServerIds(messageIds) + + assertThat(databaseIdToServerIdMapping).isEqualTo( + mapOf( + messageId1 to "uid1", + messageId2 to "uid2", + messageId3 to "uid3", + messageId4 to "uid4" + ) + ) + } + + @Test + fun `get all message server ids`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1") + sqliteDatabase.createMessage(folderId = 1, uid = "K9LOCAL:1") + sqliteDatabase.createMessage(folderId = 1, uid = "uid3") + sqliteDatabase.createMessage(folderId = 1, uid = "uid4") + + val messageServerIds = retrieveMessageOperations.getMessageServerIds(folderId = 1) + + assertThat(messageServerIds).isEqualTo(setOf("uid1", "uid3", "uid4")) + } + + @Test + fun `check if message is present`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1") + + val result = retrieveMessageOperations.isMessagePresent(folderId = 1, messageServerId = "uid1") + + assertThat(result).isTrue() + } + + @Test + fun `check if non-existent message is present`() { + val result = retrieveMessageOperations.isMessagePresent(folderId = 1, messageServerId = "uid1") + + assertThat(result).isFalse() + } + + @Test + fun `get message flags`() { + sqliteDatabase.createMessage( + folderId = 1, + uid = "uid1", + flags = "DRAFT,RECENT,X_DESTROYED,X_SEND_FAILED,X_SEND_IN_PROGRESS,X_DOWNLOADED_FULL," + + "X_DOWNLOADED_PARTIAL,X_REMOTE_COPY_STARTED,X_MIGRATED_FROM_V50,X_DRAFT_OPENPGP_INLINE," + + "X_SUBJECT_DECRYPTED", + deleted = true, + read = true, + flagged = true, + answered = true, + forwarded = true + ) + + val flags = retrieveMessageOperations.getMessageFlags(folderId = 1, messageServerId = "uid1") + + assertThat(flags).isEqualTo( + setOf( + Flag.DELETED, + Flag.SEEN, + Flag.ANSWERED, + Flag.FLAGGED, + Flag.DRAFT, + Flag.RECENT, + Flag.FORWARDED, + Flag.X_DESTROYED, + Flag.X_SEND_FAILED, + Flag.X_SEND_IN_PROGRESS, + Flag.X_DOWNLOADED_FULL, + Flag.X_DOWNLOADED_PARTIAL, + Flag.X_REMOTE_COPY_STARTED, + Flag.X_MIGRATED_FROM_V50, + Flag.X_DRAFT_OPENPGP_INLINE, + Flag.X_SUBJECT_DECRYPTED + ) + ) + } + + @Test + fun `get message flags without any flags set`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", flags = "") + + val flags = retrieveMessageOperations.getMessageFlags(folderId = 1, messageServerId = "uid1") + + assertThat(flags).isEmpty() + } + + @Test + fun `get all message server ids and dates`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", date = 23) + sqliteDatabase.createMessage(folderId = 1, uid = "K9LOCAL:1") + sqliteDatabase.createMessage(folderId = 1, uid = "uid3", date = 42) + sqliteDatabase.createMessage(folderId = 1, uid = "uid4", deleted = true) + + val result = retrieveMessageOperations.getAllMessagesAndEffectiveDates(folderId = 1) + + assertThat(result).isEqualTo( + mapOf( + "uid1" to 23L, + "uid3" to 42L + ) + ) + } + + @Test + fun `get headers`() { + val messagePartId = sqliteDatabase.createMessagePart( + header = """ + From: <alice@domain.example> + To: Bob <bob@domain.example> + Date: Thu, 01 Apr 2021 01:23:45 +0200 + Subject: Test + Message-Id: <20210401012345.123456789A@domain.example> + """.trimIndent().crlf() + ) + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", messagePartId = messagePartId) + + val headers = retrieveMessageOperations.getHeaders(folderId = 1, messageServerId = "uid1") + + assertThat(headers).isEqualTo( + listOf( + Header("From", "<alice@domain.example>"), + Header("To", "Bob <bob@domain.example>"), + Header("Date", "Thu, 01 Apr 2021 01:23:45 +0200"), + Header("Subject", "Test"), + Header("Message-Id", "<20210401012345.123456789A@domain.example>") + ) + ) + } + + @Test + fun `get some headers`() { + val messagePartId = sqliteDatabase.createMessagePart( + header = """ + From: <alice@domain.example> + To: Bob <bob@domain.example> + Date: Thu, 01 Apr 2021 01:23:45 +0200 + Subject: Test + Message-Id: <20210401012345.123456789A@domain.example> + """.trimIndent().crlf() + ) + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", messagePartId = messagePartId) + + val headers = retrieveMessageOperations.getHeaders( + folderId = 1, + messageServerId = "uid1", + headerNames = setOf("from", "to", "message-id") + ) + + assertThat(headers).isEqualTo( + listOf( + Header("From", "<alice@domain.example>"), + Header("To", "Bob <bob@domain.example>"), + Header("Message-Id", "<20210401012345.123456789A@domain.example>") + ) + ) + } + + @Test + fun `get oldest message date`() { + sqliteDatabase.createMessage(folderId = 1, date = 42) + sqliteDatabase.createMessage(folderId = 1, date = 23) + + val oldestMessageDate = retrieveMessageOperations.getOldestMessageDate(folderId = 1) + + assertThat(oldestMessageDate).isEqualTo(Date(23)) + } + + @Test + fun `get oldest message date without any messages in the store`() { + val oldestMessageDate = retrieveMessageOperations.getOldestMessageDate(folderId = 1) + + assertThat(oldestMessageDate).isNull() + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/SaveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/SaveMessageOperationsTest.kt new file mode 100644 index 0000000..b5b3f3e --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/SaveMessageOperationsTest.kt @@ -0,0 +1,556 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.startsWith +import com.fsck.k9.K9 +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.Multipart +import com.fsck.k9.mail.Part +import com.fsck.k9.mail.buildMessage +import com.fsck.k9.mailstore.SaveMessageData +import com.fsck.k9.mailstore.StorageManager +import com.fsck.k9.message.extractors.BasicPartInfoExtractor +import com.fsck.k9.message.extractors.PreviewResult +import com.fsck.k9.storage.RobolectricTest +import java.io.ByteArrayOutputStream +import java.util.Stack +import org.junit.After +import org.junit.Test +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock + +private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000" + +class SaveMessageOperationsTest : RobolectricTest() { + private val messagePartDirectory = createRandomTempDirectory() + private val sqliteDatabase = createDatabase() + private val storageManager = mock<StorageManager> { + on { getAttachmentDirectory(eq(ACCOUNT_UUID), anyOrNull()) } doReturn messagePartDirectory + } + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val attachmentFileManager = AttachmentFileManager(storageManager, ACCOUNT_UUID) + private val basicPartInfoExtractor = BasicPartInfoExtractor() + private val threadMessageOperations = ThreadMessageOperations() + private val saveMessageOperations = SaveMessageOperations( + lockableDatabase, + attachmentFileManager, + basicPartInfoExtractor, + threadMessageOperations + ) + + @After + fun tearDown() { + messagePartDirectory.deleteRecursively() + } + + @Test + fun `save message with text_plain body`() { + val messageData = buildMessage { + header("Subject", "Test Message") + header("From", "alice@domain.example") + header("To", "Bob <bob@domain.example>") + header("Cc", "<cloe@domain.example>") + header("Date", "Mon, 12 Apr 2021 03:42:00 +0200") + header("Message-ID", "<msg0001@domain.example>") + + textBody("Text") + }.apply { + setFlag(Flag.FLAGGED, true) + setFlag(Flag.SEEN, true) + setFlag(Flag.ANSWERED, true) + setFlag(Flag.FORWARDED, true) + }.toSaveMessageData( + previewResult = PreviewResult.text("Preview") + ) + + saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + val message = messages.first() + with(message) { + assertThat(deleted).isEqualTo(0) + assertThat(folderId).isEqualTo(1) + assertThat(uid).isEqualTo("uid1") + assertThat(subject).isEqualTo("Test Message") + assertThat(date).isEqualTo(1618191720000L) + assertThat(internalDate).isEqualTo(1618191720000L) + assertThat(flags).isEqualTo("X_DOWNLOADED_FULL") + assertThat(senderList).isEqualTo("alice@domain.example") + assertThat(toList).isEqualTo(Address.pack(Address.parse("Bob <bob@domain.example>"))) + assertThat(ccList).isEqualTo("cloe@domain.example") + assertThat(bccList).isEqualTo("") + assertThat(replyToList).isEqualTo("") + assertThat(attachmentCount).isEqualTo(0) + assertThat(messageId).isEqualTo("<msg0001@domain.example>") + assertThat(previewType).isEqualTo("text") + assertThat(preview).isEqualTo("Preview") + assertThat(mimeType).isEqualTo("text/plain") + assertThat(empty).isEqualTo(0) + assertThat(read).isEqualTo(1) + assertThat(flagged).isEqualTo(1) + assertThat(answered).isEqualTo(1) + assertThat(forwarded).isEqualTo(1) + assertThat(encryptionType).isNull() + } + + val messageParts = sqliteDatabase.readMessageParts() + assertThat(messageParts).hasSize(1) + val messagePart = messageParts.first() + with(messagePart) { + assertThat(type).isEqualTo(MessagePartType.UNKNOWN) + assertThat(root).isEqualTo(messagePart.id) + assertThat(parent).isEqualTo(-1) + assertThat(seq).isEqualTo(0) + assertThat(mimeType).isEqualTo("text/plain") + assertThat(displayName).isEqualTo("noname.txt") + assertThat(header?.toString(Charsets.UTF_8)).isEqualTo(messageData.message.header()) + assertThat(encoding).isEqualTo("quoted-printable") + assertThat(charset).isNull() + assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE) + assertThat(decodedBodySize).isEqualTo(4) + assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("Text") + assertThat(preamble).isNull() + assertThat(epilogue).isNull() + assertThat(boundary).isNull() + assertThat(contentId).isNull() + assertThat(serverExtra).isNull() + } + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(1) + val thread = threads.first() + with(thread) { + assertThat(messageId).isEqualTo(message.id) + assertThat(root).isEqualTo(id) + assertThat(parent).isNull() + } + } + + @Test + fun `save message with multipart body`() { + val messageData = buildMessage { + multipart("alternative") { + bodyPart("text/plain") { + textBody("plain") + } + bodyPart("text/html") { + textBody("html") + } + } + }.toSaveMessageData() + + saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + val messageParts = sqliteDatabase.readMessageParts() + assertThat(messageParts).hasSize(3) + + val rootMessagePart = messageParts.first { it.seq == 0 } + with(rootMessagePart) { + assertThat(type).isEqualTo(MessagePartType.UNKNOWN) + assertThat(root).isEqualTo(id) + assertThat(parent).isEqualTo(-1) + assertThat(mimeType).isEqualTo("multipart/alternative") + assertThat(displayName).isNull() + assertThat(header?.toString(Charsets.UTF_8)).isEqualTo(messageData.message.header()) + assertThat(encoding).isNull() + assertThat(charset).isNull() + assertThat(dataLocation).isEqualTo(DataLocation.CHILD_PART_CONTAINS_DATA) + assertThat(decodedBodySize).isNull() + assertThat(data).isNull() + assertThat(preamble).isNull() + assertThat(epilogue).isNull() + assertThat(boundary).isEqualTo(messageData.message.boundary()) + assertThat(contentId).isNull() + assertThat(serverExtra).isNull() + } + + val textPlainMessagePart = messageParts.first { it.seq == 1 } + with(textPlainMessagePart) { + assertThat(type).isEqualTo(MessagePartType.UNKNOWN) + assertThat(root).isEqualTo(rootMessagePart.id) + assertThat(parent).isEqualTo(rootMessagePart.id) + assertThat(mimeType).isEqualTo("text/plain") + assertThat(displayName).isEqualTo("noname.txt") + assertThat(header).isNotNull() + assertThat(encoding).isEqualTo("quoted-printable") + assertThat(charset).isNull() + assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE) + assertThat(decodedBodySize).isEqualTo(5) + assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("plain") + assertThat(preamble).isNull() + assertThat(epilogue).isNull() + assertThat(boundary).isNull() + assertThat(contentId).isNull() + assertThat(serverExtra).isNull() + } + + val textHtmlMessagePart = messageParts.first { it.seq == 2 } + with(textHtmlMessagePart) { + assertThat(type).isEqualTo(MessagePartType.UNKNOWN) + assertThat(root).isEqualTo(rootMessagePart.id) + assertThat(parent).isEqualTo(rootMessagePart.id) + assertThat(mimeType).isEqualTo("text/html") + assertThat(displayName).isEqualTo("noname.html") + assertThat(header).isNotNull() + assertThat(encoding).isEqualTo("quoted-printable") + assertThat(charset).isNull() + assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE) + assertThat(decodedBodySize).isEqualTo(4) + assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("html") + assertThat(preamble).isNull() + assertThat(epilogue).isNull() + assertThat(boundary).isNull() + assertThat(contentId).isNull() + assertThat(serverExtra).isNull() + } + } + + @Test + fun `save message into existing thread`() { + val messageId1 = sqliteDatabase.createMessage( + folderId = 1, + empty = true, + messageIdHeader = "<msg0001@domain.example>" + ) + val messageId2 = sqliteDatabase.createMessage( + folderId = 1, + empty = true, + messageIdHeader = "<msg0002@domain.example>" + ) + val messageId3 = sqliteDatabase.createMessage( + folderId = 1, + empty = false, + messageIdHeader = "<msg0003@domain.example>" + ) + val threadId1 = sqliteDatabase.createThread(messageId1) + val threadId2 = sqliteDatabase.createThread(messageId2, root = threadId1, parent = threadId1) + val threadId3 = sqliteDatabase.createThread(messageId3, root = threadId1, parent = threadId2) + val messageData = buildMessage { + header("Message-ID", "<msg0002@domain.example>") + header("In-Reply-To", "<msg0001@domain.example>") + + textBody() + }.toSaveMessageData() + + saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(3) + + assertThat(threads.first { it.id == threadId1 }).isEqualTo( + ThreadEntry( + id = threadId1, + messageId = messageId1, + root = threadId1, + parent = null + ) + ) + + assertThat(threads.first { it.id == threadId2 }).isEqualTo( + ThreadEntry( + id = threadId2, + messageId = messageId2, + root = threadId1, + parent = threadId1 + ) + ) + + assertThat(threads.first { it.id == threadId3 }).isEqualTo( + ThreadEntry( + id = threadId3, + messageId = messageId3, + root = threadId1, + parent = threadId2 + ) + ) + } + + @Test + fun `save message with references header should create empty messages`() { + val messageData = buildMessage { + header("Message-ID", "<msg0003@domain.example>") + header("In-Reply-To", "<msg0002@domain.example>") + header("References", "<msg0001@domain.example> <msg0002@domain.example>") + + textBody() + }.toSaveMessageData() + + saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(3) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(3) + + val thread1 = threads.first { it.id == it.root } + val message1 = messages.first { it.id == thread1.messageId } + assertThat(message1.empty).isEqualTo(1) + + val thread2 = threads.first { it.parent == thread1.id } + val message2 = messages.first { it.id == thread2.messageId } + assertThat(message2.empty).isEqualTo(1) + + val thread3 = threads.first { it.parent == thread2.id } + val message3 = messages.first { it.id == thread3.messageId } + assertThat(message3.empty).isEqualTo(0) + assertThat(message3.uid).isEqualTo("uid1") + } + + @Test + fun `save message with server ID already existing in MessageStore should replace that message`() { + val existingMessageData = buildMessage { + multipart("alternative") { + bodyPart("text/plain") { + textBody("plain") + } + bodyPart("text/html") { + textBody("html") + } + } + }.toSaveMessageData() + saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", existingMessageData) + val messageData = buildMessage { + textBody("new") + }.toSaveMessageData() + + saveMessageOperations.saveRemoteMessage(folderId = 1, messageServerId = "uid1", messageData) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + val messageParts = sqliteDatabase.readMessageParts() + assertThat(messageParts).hasSize(1) + + val messagePart = messageParts.first() + with(messagePart) { + assertThat(type).isEqualTo(MessagePartType.UNKNOWN) + assertThat(root).isEqualTo(messagePart.id) + assertThat(parent).isEqualTo(-1) + assertThat(mimeType).isEqualTo("text/plain") + assertThat(displayName).isEqualTo("noname.txt") + assertThat(header).isNotNull() + assertThat(encoding).isEqualTo("quoted-printable") + assertThat(charset).isNull() + assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE) + assertThat(decodedBodySize).isEqualTo(3) + assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("new") + assertThat(preamble).isNull() + assertThat(epilogue).isNull() + assertThat(boundary).isNull() + assertThat(contentId).isNull() + assertThat(serverExtra).isNull() + } + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(1) + + val thread = threads.first() + val message = messages.first() + assertThat(thread.root).isEqualTo(thread.id) + assertThat(thread.parent).isNull() + assertThat(thread.messageId).isEqualTo(message.id) + } + + @Test + fun `save local message`() { + val messageData = buildMessage { + textBody("local") + }.toSaveMessageData( + subject = "Provided subject", + date = 1618191720000L, + internalDate = 1618191720000L, + previewResult = PreviewResult.text("Preview") + ) + + val newMessageId = saveMessageOperations.saveLocalMessage(folderId = 1, messageData, existingMessageId = null) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + val message = messages.first() + with(message) { + assertThat(id).isEqualTo(newMessageId) + assertThat(deleted).isEqualTo(0) + assertThat(folderId).isEqualTo(1) + assertThat(uid).isNotNull().startsWith(K9.LOCAL_UID_PREFIX) + assertThat(subject).isEqualTo("Provided subject") + assertThat(date).isEqualTo(1618191720000L) + assertThat(internalDate).isEqualTo(1618191720000L) + assertThat(flags).isEqualTo("X_DOWNLOADED_FULL") + assertThat(senderList).isEqualTo("") + assertThat(toList).isEqualTo("") + assertThat(ccList).isEqualTo("") + assertThat(bccList).isEqualTo("") + assertThat(replyToList).isEqualTo("") + assertThat(attachmentCount).isEqualTo(0) + assertThat(messageId).isNull() + assertThat(previewType).isEqualTo("text") + assertThat(preview).isEqualTo("Preview") + assertThat(mimeType).isEqualTo("text/plain") + assertThat(empty).isEqualTo(0) + assertThat(read).isEqualTo(0) + assertThat(flagged).isEqualTo(0) + assertThat(answered).isEqualTo(0) + assertThat(forwarded).isEqualTo(0) + assertThat(encryptionType).isNull() + } + + val messageParts = sqliteDatabase.readMessageParts() + assertThat(messageParts).hasSize(1) + + val messagePart = messageParts.first() + with(messagePart) { + assertThat(type).isEqualTo(MessagePartType.UNKNOWN) + assertThat(root).isEqualTo(messagePart.id) + assertThat(parent).isEqualTo(-1) + assertThat(mimeType).isEqualTo("text/plain") + assertThat(displayName).isEqualTo("noname.txt") + assertThat(header).isNotNull() + assertThat(encoding).isEqualTo("quoted-printable") + assertThat(charset).isNull() + assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE) + assertThat(decodedBodySize).isEqualTo(5) + assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("local") + assertThat(preamble).isNull() + assertThat(epilogue).isNull() + assertThat(boundary).isNull() + assertThat(contentId).isNull() + assertThat(serverExtra).isNull() + } + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(1) + + val thread = threads.first() + assertThat(thread.root).isEqualTo(thread.id) + assertThat(thread.parent).isNull() + assertThat(thread.messageId).isEqualTo(message.id) + } + + @Test + fun `replace local message`() { + val existingMessageData = buildMessage { + multipart("alternative") { + bodyPart("text/plain") { + textBody("plain") + } + bodyPart("text/html") { + textBody("html") + } + } + }.toSaveMessageData() + val existingMessageId = saveMessageOperations.saveLocalMessage( + folderId = 1, + existingMessageData, + existingMessageId = null + ) + val messageData = buildMessage { + textBody("new") + }.toSaveMessageData() + + val newMessageId = saveMessageOperations.saveLocalMessage(folderId = 1, messageData, existingMessageId) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + assertThat(messages.first().id).isEqualTo(newMessageId) + + val messageParts = sqliteDatabase.readMessageParts() + assertThat(messageParts).hasSize(1) + + val messagePart = messageParts.first() + with(messagePart) { + assertThat(type).isEqualTo(MessagePartType.UNKNOWN) + assertThat(root).isEqualTo(messagePart.id) + assertThat(parent).isEqualTo(-1) + assertThat(mimeType).isEqualTo("text/plain") + assertThat(displayName).isEqualTo("noname.txt") + assertThat(header).isNotNull() + assertThat(encoding).isEqualTo("quoted-printable") + assertThat(charset).isNull() + assertThat(dataLocation).isEqualTo(DataLocation.IN_DATABASE) + assertThat(decodedBodySize).isEqualTo(3) + assertThat(data?.toString(Charsets.UTF_8)).isEqualTo("new") + assertThat(preamble).isNull() + assertThat(epilogue).isNull() + assertThat(boundary).isNull() + assertThat(contentId).isNull() + assertThat(serverExtra).isNull() + } + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(1) + + val thread = threads.first() + val message = messages.first() + assertThat(thread.root).isEqualTo(thread.id) + assertThat(thread.parent).isNull() + assertThat(thread.messageId).isEqualTo(message.id) + } + + private fun Message.toSaveMessageData( + subject: String? = getSubject(), + date: Long = sentDate?.time ?: System.currentTimeMillis(), + internalDate: Long = date, + downloadState: MessageDownloadState = getDownloadState(), + attachmentCount: Int = 0, + previewResult: PreviewResult = PreviewResult.none(), + textForSearchIndex: String? = null, + encryptionType: String? = null + ): SaveMessageData { + return SaveMessageData( + message = this, + subject, + date, + internalDate, + downloadState, + attachmentCount, + previewResult, + textForSearchIndex, + encryptionType + ) + } + + private fun Message.getDownloadState(): MessageDownloadState { + if (body == null) return MessageDownloadState.ENVELOPE + + val stack = Stack<Part>() + stack.push(this) + + while (stack.isNotEmpty()) { + val part = stack.pop() + when (val body = part.body) { + null -> return MessageDownloadState.PARTIAL + is Multipart -> { + for (i in 0 until body.count) { + stack.push(body.getBodyPart(i)) + } + } + } + } + + return MessageDownloadState.FULL + } + + private fun Message.header(): String { + val outputStream = ByteArrayOutputStream() + writeHeaderTo(outputStream) + return outputStream.toString("UTF-8") + } + + private fun Message.boundary(): String? = (body as Multipart).boundary +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadDatabaseHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadDatabaseHelpers.kt new file mode 100644 index 0000000..31831f1 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadDatabaseHelpers.kt @@ -0,0 +1,40 @@ +package com.fsck.k9.storage.messages + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import app.k9mail.core.android.common.database.getLongOrNull +import app.k9mail.core.android.common.database.map + +fun SQLiteDatabase.createThread( + messageId: Long, + root: Long? = null, + parent: Long? = null +): Long { + val values = ContentValues().apply { + put("message_id", messageId) + put("root", root) + put("parent", parent) + } + + return insert("threads", null, values) +} + +fun SQLiteDatabase.readThreads(): List<ThreadEntry> { + return rawQuery("SELECT * FROM threads", null).use { cursor -> + cursor.map { + ThreadEntry( + id = cursor.getLongOrNull("id"), + messageId = cursor.getLongOrNull("message_id"), + root = cursor.getLongOrNull("root"), + parent = cursor.getLongOrNull("parent") + ) + } + } +} + +data class ThreadEntry( + val id: Long?, + val messageId: Long?, + val root: Long?, + val parent: Long? +) diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadMessageOperationsTest.kt new file mode 100644 index 0000000..658cfb6 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/ThreadMessageOperationsTest.kt @@ -0,0 +1,266 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test + +class ThreadMessageOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val threadMessageOperations = ThreadMessageOperations() + + @Test + fun `insert message without existing thread`() { + val threadInfo = threadMessageOperations.doMessageThreading( + sqliteDatabase, + folderId = 1, + ThreadHeaders(messageIdHeader = "<msg001@domain.example>", inReplyToHeader = null, referencesHeader = null) + ) + + assertThat(threadInfo).isNull() + + assertThat(sqliteDatabase.readThreads()).isEmpty() + } + + @Test + fun `insert message with in-reply-to header without existing thread`() { + val threadInfo = threadMessageOperations.doMessageThreading( + sqliteDatabase, + folderId = 1, + ThreadHeaders( + messageIdHeader = "<msg002@domain.example>", + inReplyToHeader = "<msg001@domain.example>", + referencesHeader = null + ) + ) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(1) + + val rootThread = threads.first { it.id == it.root } + assertThat(rootThread.parent).isNull() + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + val emptyMessage = messages.first() + assertThat(emptyMessage.id).isEqualTo(rootThread.messageId) + assertThat(emptyMessage.empty).isEqualTo(1) + assertThat(emptyMessage.messageId).isEqualTo("<msg001@domain.example>") + + assertThat(threadInfo).isEqualTo( + ThreadInfo( + threadId = null, + messageId = null, + rootId = rootThread.id!!, + parentId = rootThread.id + ) + ) + } + + @Test + fun `insert message with in-reply-to header into existing thread`() { + val messageId = sqliteDatabase.createMessage( + folderId = 1, + empty = false, + messageIdHeader = "<msg001@domain.example>" + ) + val threadId = sqliteDatabase.createThread(messageId) + + val threadInfo = threadMessageOperations.doMessageThreading( + sqliteDatabase, + folderId = 1, + ThreadHeaders( + messageIdHeader = "<msg002@domain.example>", + inReplyToHeader = "<msg001@domain.example>", + referencesHeader = null + ) + ) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(1) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + assertThat(threadInfo).isEqualTo( + ThreadInfo( + threadId = null, + messageId = null, + rootId = threadId, + parentId = threadId + ) + ) + } + + @Test + fun `insert message into existing thread as thread root`() { + val messageId1 = sqliteDatabase.createMessage( + folderId = 1, + empty = true, + messageIdHeader = "<msg001@domain.example>" + ) + val messageId2 = sqliteDatabase.createMessage( + folderId = 1, + empty = false, + messageIdHeader = "<msg002@domain.example>" + ) + val threadId1 = sqliteDatabase.createThread(messageId1) + sqliteDatabase.createThread(messageId2, root = threadId1, parent = threadId1) + + val threadInfo = threadMessageOperations.doMessageThreading( + sqliteDatabase, + folderId = 1, + ThreadHeaders( + messageIdHeader = "<msg001@domain.example>", + inReplyToHeader = null, + referencesHeader = null + ) + ) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(2) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(2) + + assertThat(threadInfo).isEqualTo( + ThreadInfo( + threadId = threadId1, + messageId = messageId1, + rootId = threadId1, + parentId = null + ) + ) + } + + @Test + fun `insert message into existing thread changing the thread root`() { + val messageId1 = sqliteDatabase.createMessage( + folderId = 1, + messageIdHeader = "<msg001@domain.example>", + empty = true + ) + val messageId2 = sqliteDatabase.createMessage( + folderId = 1, + messageIdHeader = "<msg002@domain.example>", + empty = true + ) + val messageId3 = sqliteDatabase.createMessage( + folderId = 1, + messageIdHeader = "<msg003@domain.example>", + empty = false + ) + val threadId1 = sqliteDatabase.createThread(messageId1) + val threadId2 = sqliteDatabase.createThread(messageId2, root = threadId1, parent = threadId1) + val threadId3 = sqliteDatabase.createThread(messageId3, root = threadId1, parent = threadId2) + + val threadInfo = threadMessageOperations.doMessageThreading( + sqliteDatabase, + folderId = 1, + ThreadHeaders( + messageIdHeader = "<msg002@domain.example>", + inReplyToHeader = "<msg001@domain.example>", + referencesHeader = "<msg000@domain.example> <msg001@domain.example>" + ) + ) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(4) + + val newRootThread = threads.first { it.id !in setOf(threadId1, threadId2, threadId3) } + assertThat(newRootThread.root).isEqualTo(newRootThread.id) + assertThat(newRootThread.parent).isNull() + + for (thread in threads) { + assertThat(thread.root).isEqualTo(newRootThread.id) + } + + assertThat(threadInfo).isEqualTo( + ThreadInfo( + threadId = threadId2, + messageId = messageId2, + rootId = newRootThread.id!!, + parentId = threadId1 + ) + ) + } + + @Test + fun `merge two existing threads`() { + val messageIdB = sqliteDatabase.createMessage( + folderId = 1, + messageIdHeader = "<msgB@domain.example>", + empty = true + ) + val messageIdC = sqliteDatabase.createMessage( + folderId = 1, + messageIdHeader = "<msgC@domain.example>", + empty = false + ) + val messageIdD = sqliteDatabase.createMessage( + folderId = 1, + messageIdHeader = "<msgD@domain.example>", + empty = true + ) + val messageIdE = sqliteDatabase.createMessage( + folderId = 1, + messageIdHeader = "<msgE@domain.example>", + empty = false + ) + val threadIdB = sqliteDatabase.createThread(messageIdB) + val threadIdC = sqliteDatabase.createThread(messageIdC, root = threadIdB, parent = threadIdB) + val threadIdD = sqliteDatabase.createThread(messageIdD) + val threadIdE = sqliteDatabase.createThread(messageIdE, root = threadIdD, parent = threadIdD) + + val threadInfo = threadMessageOperations.doMessageThreading( + sqliteDatabase, + folderId = 1, + ThreadHeaders( + messageIdHeader = "<msgD@domain.example>", + inReplyToHeader = "<msgC@domain.example>", + referencesHeader = "<msgA@domain.example>" + ) + ) + + val threads = sqliteDatabase.readThreads() + assertThat(threads).hasSize(4 + 1) + + val threadRoots = threads.filter { it.root == it.id } + assertThat(threadRoots).hasSize(1) + + val threadA = threadRoots.first() + for (thread in threads) { + assertThat(thread.root).isEqualTo(threadA.id) + } + + val messages = sqliteDatabase.readMessages() + val messageA = messages.first { it.id == threadA.messageId } + assertThat(messageA.empty).isEqualTo(1) + assertThat(messageA.messageId).isEqualTo("<msgA@domain.example>") + + val threadB = threads.first { it.id == threadIdB } + assertThat(threadB.parent).isEqualTo(threadA.id) + + val threadC = threads.first { it.id == threadIdC } + assertThat(threadC.parent).isEqualTo(threadB.id) + + val threadD = threads.first { it.id == threadIdD } + assertThat(threadD.parent).isEqualTo(threadC.id) + + val threadE = threads.first { it.id == threadIdE } + assertThat(threadE.parent).isEqualTo(threadD.id) + + assertThat(threadInfo).isEqualTo( + ThreadInfo( + threadId = threadIdD, + messageId = messageIdD, + rootId = threadA.id!!, + parentId = threadIdC + ) + ) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt new file mode 100644 index 0000000..86283f8 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt @@ -0,0 +1,167 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.FolderClass +import com.fsck.k9.mailstore.Folder +import com.fsck.k9.mailstore.FolderDetails +import com.fsck.k9.mailstore.FolderType +import com.fsck.k9.mailstore.MoreMessages +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test +import com.fsck.k9.mail.FolderType as RemoteFolderType + +class UpdateFolderOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val updateFolderOperations = UpdateFolderOperations(lockableDatabase) + + @Test + fun `change folder`() { + sqliteDatabase.createFolder(serverId = "folder1", name = "Old", type = "REGULAR") + + updateFolderOperations.changeFolder("folder1", "New", RemoteFolderType.TRASH) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.serverId).isEqualTo("folder1") + assertThat(folder.name).isEqualTo("New") + assertThat(folder.type).isEqualTo("trash") + } + + @Test + fun `update folder settings`() { + val folderId = sqliteDatabase.createFolder( + inTopGroup = false, + integrate = false, + displayClass = "NO_CLASS", + syncClass = "NO_CLASS", + notifyClass = "NO_CLASS", + pushClass = "NO_CLASS" + ) + + updateFolderOperations.updateFolderSettings( + FolderDetails( + folder = Folder( + id = folderId, + name = "irrelevant", + type = FolderType.REGULAR, + isLocalOnly = false + ), + isInTopGroup = true, + isIntegrate = true, + displayClass = FolderClass.FIRST_CLASS, + syncClass = FolderClass.FIRST_CLASS, + notifyClass = FolderClass.FIRST_CLASS, + pushClass = FolderClass.FIRST_CLASS + ) + ) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.inTopGroup).isEqualTo(1) + assertThat(folder.integrate).isEqualTo(1) + assertThat(folder.displayClass).isEqualTo("FIRST_CLASS") + assertThat(folder.syncClass).isEqualTo("FIRST_CLASS") + assertThat(folder.notifyClass).isEqualTo("FIRST_CLASS") + assertThat(folder.pushClass).isEqualTo("FIRST_CLASS") + } + + @Test + fun `update integrate setting`() { + val folderId = sqliteDatabase.createFolder(integrate = false) + + updateFolderOperations.setIncludeInUnifiedInbox(folderId = folderId, includeInUnifiedInbox = true) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.integrate).isEqualTo(1) + } + + @Test + fun `update display class`() { + val folderId = sqliteDatabase.createFolder(displayClass = "FIRST_CLASS") + + updateFolderOperations.setDisplayClass(folderId = folderId, folderClass = FolderClass.SECOND_CLASS) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.displayClass).isEqualTo("SECOND_CLASS") + } + + @Test + fun `update sync class`() { + val folderId = sqliteDatabase.createFolder(syncClass = "FIRST_CLASS") + + updateFolderOperations.setSyncClass(folderId = folderId, folderClass = FolderClass.NO_CLASS) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.syncClass).isEqualTo("NO_CLASS") + } + + @Test + fun `update push class`() { + val folderId = sqliteDatabase.createFolder(pushClass = "FIRST_CLASS") + + updateFolderOperations.setPushClass(folderId = folderId, folderClass = FolderClass.NO_CLASS) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.pushClass).isEqualTo("NO_CLASS") + } + + @Test + fun `update notification class`() { + val folderId = sqliteDatabase.createFolder(syncClass = "FIRST_CLASS") + + updateFolderOperations.setNotificationClass(folderId = folderId, folderClass = FolderClass.INHERITED) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.notifyClass).isEqualTo("INHERITED") + } + + @Test + fun `update more messages state`() { + val folderId = sqliteDatabase.createFolder(moreMessages = "unknown") + + updateFolderOperations.setMoreMessages(folderId = folderId, moreMessages = MoreMessages.TRUE) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.moreMessages).isEqualTo("true") + } + + @Test + fun `update late updated state`() { + val folderId = sqliteDatabase.createFolder(lastUpdated = 23) + + updateFolderOperations.setLastChecked(folderId = folderId, timestamp = 42) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.lastUpdated).isEqualTo(42) + } + + @Test + fun `update folder status`() { + val folderId = sqliteDatabase.createFolder(status = null) + + updateFolderOperations.setStatus(folderId = folderId, status = "Sync error") + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.status).isEqualTo("Sync error") + } + + @Test + fun `update visible limit`() { + val folderId = sqliteDatabase.createFolder(visibleLimit = 10) + + updateFolderOperations.setVisibleLimit(folderId = folderId, visibleLimit = 25) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.visibleLimit).isEqualTo(25) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt new file mode 100644 index 0000000..8bc95c4 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt @@ -0,0 +1,54 @@ +package com.fsck.k9.storage.messages + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import com.fsck.k9.storage.RobolectricTest +import org.junit.Test + +class UpdateMessageOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val updateMessageOperations = UpdateMessageOperations(lockableDatabase) + + @Test + fun `mark message as new`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = false) + + updateMessageOperations.setNewMessageState(folderId = 1, messageServerId = "uid1", newMessage = true) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + val message = messages.first() + assertThat(message.newMessage).isEqualTo(1) + } + + @Test + fun `mark message as not new`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = true) + + updateMessageOperations.setNewMessageState(folderId = 1, messageServerId = "uid1", newMessage = false) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + val message = messages.first() + assertThat(message.newMessage).isEqualTo(0) + } + + @Test + fun `clear new message state`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = true) + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = false) + + updateMessageOperations.clearNewMessageState() + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(2) + + for (message in messages) { + assertThat(message.newMessage).isEqualTo(0) + } + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt new file mode 100644 index 0000000..108cd78 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt @@ -0,0 +1,107 @@ +package com.fsck.k9.storage.notifications + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.notification.NotificationStoreOperation +import com.fsck.k9.storage.RobolectricTest +import com.fsck.k9.storage.messages.createDatabase +import com.fsck.k9.storage.messages.createLockableDatabaseMock +import com.fsck.k9.storage.messages.createMessage +import org.junit.Test + +private const val FOLDER_ID = 1L + +class K9NotificationStoreTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val store = K9NotificationStore(lockableDatabase) + private val messageIdOne = sqliteDatabase.createMessage(folderId = FOLDER_ID, uid = "uid-1") + private val messageIdTwo = sqliteDatabase.createMessage(folderId = FOLDER_ID, uid = "uid-2") + + @Test + fun `add notification`() { + val operations = listOf( + NotificationStoreOperation.Add( + messageReference = createMessageReference("uid-1"), + notificationId = 42, + timestamp = 23L + ) + ) + + store.persistNotificationChanges(operations) + + val notifications = sqliteDatabase.readNotifications() + assertThat(notifications).hasSize(1) + val notification = notifications.first() + assertThat(notification.messageId).isEqualTo(messageIdOne) + assertThat(notification.notificationId).isEqualTo(42) + assertThat(notification.timestamp).isEqualTo(23L) + } + + @Test + fun `replace notification when adding`() { + sqliteDatabase.createNotification(messageId = messageIdOne, notificationId = 10, timestamp = 20L) + val operations = listOf( + NotificationStoreOperation.ChangeToInactive(messageReference = createMessageReference("uid-1")), + NotificationStoreOperation.Add( + messageReference = createMessageReference("uid-2"), + notificationId = 10, + timestamp = 30L + ) + ) + + store.persistNotificationChanges(operations) + + val notifications = sqliteDatabase.readNotifications() + assertThat(notifications).hasSize(2) + val originalNotification = notifications.first { it.messageId == messageIdOne } + assertThat(originalNotification.notificationId).isNull() + assertThat(originalNotification.timestamp).isEqualTo(20L) + val newNotification = notifications.first { it.messageId == messageIdTwo } + assertThat(newNotification.notificationId).isEqualTo(10) + assertThat(newNotification.timestamp).isEqualTo(30L) + } + + @Test + fun `remove notification`() { + sqliteDatabase.createNotification(messageId = messageIdOne, notificationId = 10, timestamp = 20L) + val operations = listOf( + NotificationStoreOperation.Remove(messageReference = createMessageReference("uid-1")) + ) + + store.persistNotificationChanges(operations) + + val notifications = sqliteDatabase.readNotifications() + assertThat(notifications).isEmpty() + } + + @Test + fun `replace notification when removing`() { + sqliteDatabase.createNotification(messageId = messageIdOne, notificationId = null, timestamp = 20L) + sqliteDatabase.createNotification(messageId = messageIdTwo, notificationId = 23, timestamp = 21L) + val operations = listOf( + NotificationStoreOperation.Remove(messageReference = createMessageReference("uid-2")), + NotificationStoreOperation.ChangeToActive( + messageReference = createMessageReference("uid-1"), + notificationId = 23 + ) + ) + + store.persistNotificationChanges(operations) + + val notifications = sqliteDatabase.readNotifications() + assertThat(notifications).hasSize(1) + val notification = notifications.first() + assertThat(notification.messageId).isEqualTo(messageIdOne) + assertThat(notification.notificationId).isEqualTo(23) + assertThat(notification.timestamp).isEqualTo(20L) + } + + private fun createMessageReference(uid: String): MessageReference { + return MessageReference(accountUuid = "00000000-0000-4000-0000-000000000000", FOLDER_ID, uid) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt new file mode 100644 index 0000000..a2780e6 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt @@ -0,0 +1,40 @@ +package com.fsck.k9.storage.notifications + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import app.k9mail.core.android.common.database.getIntOrNull +import app.k9mail.core.android.common.database.getLongOrNull +import app.k9mail.core.android.common.database.map + +fun SQLiteDatabase.createNotification( + messageId: Long, + notificationId: Int? = null, + timestamp: Long = 0L +): Long { + val values = ContentValues().apply { + put("message_id", messageId) + put("notification_id", notificationId) + put("timestamp", timestamp) + } + + return insert("notifications", null, values) +} + +fun SQLiteDatabase.readNotifications(): List<NotificationEntry> { + val cursor = rawQuery("SELECT * FROM notifications", null) + return cursor.use { + cursor.map { + NotificationEntry( + messageId = cursor.getLongOrNull("message_id"), + notificationId = cursor.getIntOrNull("notification_id"), + timestamp = cursor.getLongOrNull("timestamp") + ) + } + } +} + +data class NotificationEntry( + val messageId: Long?, + val notificationId: Int?, + val timestamp: Long? +) |
