Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 5cf3b692 authored by Rafael Tonholo's avatar Rafael Tonholo
Browse files

refactor(outbox): add unit tests to DefaultOutboxFolderManager

parent 7d4cdebd
Loading
Loading
Loading
Loading
+411 −0
Original line number Original line Diff line number Diff line
package com.fsck.k9.mailstore.folder

import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mailstore.LocalStore
import com.fsck.k9.mailstore.LocalStoreProvider
import com.fsck.k9.mailstore.LockableDatabase
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.android.account.Identity
import net.thunderbird.core.android.account.LegacyAccount
import net.thunderbird.core.android.account.LegacyAccountManager
import net.thunderbird.core.architecture.model.Id
import net.thunderbird.core.common.cache.TimeLimitedCache
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.logging.testing.TestLogger
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.account.Account
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.storage.profile.AvatarDto
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
import net.thunderbird.feature.account.storage.profile.ProfileDto
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock

@OptIn(ExperimentalUuidApi::class, ExperimentalTime::class)
class DefaultOutboxFolderManagerTest {
    private val logger = TestLogger()

    @Test
    fun `getOutboxFolderId should return cached value when available`() = runTest {
        // Arrange
        val (accountId, account) = createAccountPair()
        val accountManager = FakeLegacyAccountManager(initialAccounts = listOf(account))
        val localStoreProvider = createLocalStoreProvider(account)
        val expectedFolderId = 123L
        val cache = TimeLimitedCache<AccountId, Long>()
        cache.set(accountId, expectedFolderId)
        val subject = DefaultOutboxFolderManager(
            logger = logger,
            accountManager = accountManager,
            localStoreProvider = localStoreProvider,
            outboxFolderIdCache = cache,
            ioDispatcher = Dispatchers.Unconfined,
        )

        // Act
        val result = subject.getOutboxFolderId(accountId, createIfMissing = true)

        // Assert
        assertThat(result).isEqualTo(expectedFolderId)
    }

    @Test
    fun `getOutboxFolderId should read from DB when not cached and folder exists`() = runTest {
        // Arrange
        val (accountId, account) = createAccountPair()
        val accountManager = FakeLegacyAccountManager(initialAccounts = listOf(account))
        val expectedId = 1L
        val localStoreProvider = createLocalStoreProvider(account = account, folderId = expectedId)
        val cache = TimeLimitedCache<AccountId, Long>()
        val subject = DefaultOutboxFolderManager(
            logger = logger,
            accountManager = accountManager,
            localStoreProvider = localStoreProvider,
            outboxFolderIdCache = cache,
            ioDispatcher = Dispatchers.Unconfined,
        )

        // Act
        val result = subject.getOutboxFolderId(accountId, createIfMissing = true)

        // Assert
        assertThat(result).isEqualTo(expectedId)
    }

    @Test
    fun `getOutboxFolderId should read from DB and refill cache when cached value expired`() = runTest {
        // Arrange
        val (accountId, account) = createAccountPair()
        val accountManager = FakeLegacyAccountManager(initialAccounts = listOf(account))

        val expectedFolderId = 42L
        val localStoreProvider = createLocalStoreProvider(account = account, folderId = expectedFolderId)
        val fakeClock = FakeClock(nowInstant = Clock.System.now())
        val cache = TimeLimitedCache<AccountId, Long>(clock = fakeClock)

        // Put a value into the cache and then advance time so it expires
        cache.set(accountId, 999L, expiresIn = 1.hours)
        fakeClock.advanceBy(2.hours)

        val subject = DefaultOutboxFolderManager(
            logger = logger,
            accountManager = accountManager,
            localStoreProvider = localStoreProvider,
            outboxFolderIdCache = cache,
            ioDispatcher = Dispatchers.Unconfined,
        )

        // Act
        val result = subject.getOutboxFolderId(accountId, createIfMissing = false)

        // Assert: result is read from DB and cache is repopulated
        assertThat(result).isEqualTo(expectedFolderId)
        assertThat(cache.getValue(accountId)).isEqualTo(expectedFolderId)
    }

    @Test
    fun `getOutboxFolderId should create folder when not found and createIfMissing true`() = runTest {
        // Arrange
        val (accountId, account) = createAccountPair()
        val accountManager = FakeLegacyAccountManager(initialAccounts = listOf(account))
        val cursor = mock<Cursor> {
            on { moveToFirst() } doReturn false
        }
        val expectedFolderId = 42L
        val localStoreProvider = createLocalStoreProvider(
            account = account,
            folderId = expectedFolderId,
            moveToFirst = false,
        )
        val cache = TimeLimitedCache<AccountId, Long>()
        val subject = DefaultOutboxFolderManager(
            logger = logger,
            accountManager = accountManager,
            localStoreProvider = localStoreProvider,
            outboxFolderIdCache = cache,
            ioDispatcher = Dispatchers.Unconfined,
        )

        // Act
        val result = subject.getOutboxFolderId(accountId, createIfMissing = true)

        // Assert
        assertThat(result).isEqualTo(expectedFolderId)
    }

    @Test
    fun `createOutboxFolder should return Success when LocalStore creates folder`() = runTest {
        // Arrange
        val (accountId, account) = createAccountPair()
        val accountManager = FakeLegacyAccountManager(initialAccounts = listOf(account))
        val expectedFolderId = 99L
        val localStore = mock<LocalStore> {
            on { createLocalFolder(any(), any(), any(), any()) } doReturn expectedFolderId
        }
        val localStoreProvider = mock<LocalStoreProvider> {
            on { getInstanceByLegacyAccount(account) } doReturn localStore
        }
        val cache = TimeLimitedCache<AccountId, Long>()
        val subject = DefaultOutboxFolderManager(
            logger = logger,
            accountManager = accountManager,
            localStoreProvider = localStoreProvider,
            outboxFolderIdCache = cache,
            ioDispatcher = Dispatchers.Unconfined,
        )

        // Act
        val outcome = subject.createOutboxFolder(accountId)

        // Assert
        assertThat(outcome.isSuccess).isTrue()
        val data = (outcome as Outcome.Success).data
        assertThat(data).isEqualTo(expectedFolderId)
    }

    @Test
    fun `createOutboxFolder should return Failure when LocalStore throws`() = runTest {
        // Arrange
        val (accountId, account) = createAccountPair()
        val accountManager = FakeLegacyAccountManager(initialAccounts = listOf(account))
        val localStore = mock<LocalStore> {
            on { createLocalFolder(any(), any(), any(), any()) } doAnswer { throw MessagingException("boom") }
        }
        val localStoreProvider = mock<LocalStoreProvider> {
            on { getInstanceByLegacyAccount(account) } doReturn localStore
        }
        val cache = TimeLimitedCache<AccountId, Long>()
        val subject = DefaultOutboxFolderManager(
            logger = logger,
            accountManager = accountManager,
            localStoreProvider = localStoreProvider,
            outboxFolderIdCache = cache,
            ioDispatcher = Dispatchers.Unconfined,
        )

        // Act
        val outcome = subject.createOutboxFolder(accountId)

        // Assert
        assertThat(outcome.isFailure).isTrue()
    }

    @Test
    fun `hasPendingMessages should return true when DB count is greater than zero`() = runTest {
        // Arrange
        val (accountId, account) = createAccountPair()
        val accountManager = FakeLegacyAccountManager(initialAccounts = listOf(account))
        val expectedCount = 123
        val localStoreProvider = createLocalStoreProvider(account = account, count = expectedCount)
        val cache = TimeLimitedCache<AccountId, Long>()
        val subject = DefaultOutboxFolderManager(
            logger = logger,
            accountManager = accountManager,
            localStoreProvider = localStoreProvider,
            outboxFolderIdCache = cache,
            ioDispatcher = Dispatchers.Unconfined,
        )

        // Act
        val result = subject.hasPendingMessages(accountId)

        // Assert
        assertThat(result).isTrue()
    }

    @Test
    fun `hasPendingMessages should return false when DB count is zero`() = runTest {
        // Arrange
        val (accountId, account) = createAccountPair()
        val accountManager = FakeLegacyAccountManager(initialAccounts = listOf(account))
        val expectedCount = 0
        val localStoreProvider = createLocalStoreProvider(account = account, count = expectedCount)
        val cache = TimeLimitedCache<AccountId, Long>()
        val subject = DefaultOutboxFolderManager(
            logger = logger,
            accountManager = accountManager,
            localStoreProvider = localStoreProvider,
            outboxFolderIdCache = cache,
            ioDispatcher = Dispatchers.Unconfined,
        )

        // Act
        val result = subject.hasPendingMessages(accountId)

        // Assert
        assertThat(result).isFalse()
    }

    @Test
    fun `hasPendingMessages should return false when DB throws MessagingException`() = runTest {
        // Arrange
        val (accountId, account) = createAccountPair()
        val accountManager = FakeLegacyAccountManager(initialAccounts = listOf(account))
        val localStoreProvider = createLocalStoreProvider(
            account = account,
            messagingException = MessagingException("db-fail"),
        )
        val cache = TimeLimitedCache<AccountId, Long>()
        val subject = DefaultOutboxFolderManager(
            logger = logger,
            accountManager = accountManager,
            localStoreProvider = localStoreProvider,
            outboxFolderIdCache = cache,
            ioDispatcher = Dispatchers.Unconfined,
        )

        // Act
        val result = subject.hasPendingMessages(accountId)

        // Assert
        assertThat(result).isFalse()
    }

    private fun createAccountPair(): Pair<Id<Account>, LegacyAccount> {
        val accountId = AccountIdFactory.of(Uuid.random().toString())
        val profile = ProfileDto(
            id = accountId,
            name = "name",
            color = 0,
            avatar = AvatarDto(AvatarTypeDto.MONOGRAM, "A", null, null),
        )
        val incoming = ServerSettings(
            type = "imap",
            host = "example.com",
            port = 993,
            connectionSecurity = ConnectionSecurity.NONE,
            authenticationType = AuthType.PLAIN,
            username = "user",
            password = "pass",
            clientCertificateAlias = null,
        )
        val outgoing = ServerSettings(
            type = "smtp",
            host = "example.com",
            port = 587,
            connectionSecurity = ConnectionSecurity.NONE,
            authenticationType = AuthType.PLAIN,
            username = "user",
            password = "pass",
            clientCertificateAlias = null,
        )
        return accountId to LegacyAccount(
            id = accountId,
            name = "acc",
            email = "user@example.com",
            profile = profile,
            incomingServerSettings = incoming,
            outgoingServerSettings = outgoing,
            identities = listOf(Identity(name = "n", email = "user@example.com")),
        )
    }

    private fun createLocalStoreProvider(
        account: LegacyAccount,
        folderId: Long? = 1L,
        count: Int? = null,
        moveToFirst: Boolean = folderId != null || count != null,
        messagingException: MessagingException? = null,
    ): LocalStoreProvider {
        val cursor = mock<Cursor> {
            on { moveToFirst() } doReturn moveToFirst
            folderId?.let { on { getLong(0) } doReturn it }
            count?.let { on { getInt(0) } doReturn it }
        }
        val db = mock<SQLiteDatabase> {
            if (messagingException == null) {
                on { rawQuery(any(), any()) } doReturn cursor
            } else {
                on { rawQuery(any(), any()) } doAnswer { throw messagingException }
            }
        }
        val lockableDb = mock<LockableDatabase> {
            on { execute(any(), any<LockableDatabase.DbCallback<Any>>()) } doAnswer { invocation ->
                val callback = invocation.getArgument<LockableDatabase.DbCallback<Any>>(1)
                callback.doDbWork(db)
            }
        }
        val localStore = mock<LocalStore> {
            on { database } doReturn lockableDb
            folderId?.let { folderId ->
                on {
                    createLocalFolder(any(), any(), any(), any())
                } doReturn folderId
            }
        }
        val localStoreProvider = mock<LocalStoreProvider> {
            on { getInstanceByLegacyAccount(account) } doReturn localStore
        }
        return localStoreProvider
    }
}

private class FakeLegacyAccountManager(
    initialAccounts: List<LegacyAccount> = emptyList(),
) : LegacyAccountManager {
    private val accountsState = MutableStateFlow(initialAccounts)

    override fun getAll(): Flow<List<LegacyAccount>> = accountsState

    override fun getById(id: AccountId): Flow<LegacyAccount?> =
        accountsState.map { list -> list.find { it.id == id } }

    override suspend fun update(account: LegacyAccount) {
        accountsState.update { currentList ->
            currentList.toMutableList().apply {
                removeIf { it.uuid == account.uuid }
                add(account)
            }
        }
    }

    override fun getAccounts(): List<LegacyAccount> = accountsState.value

    override fun getAccountsFlow(): Flow<List<LegacyAccount>> = accountsState

    override fun getAccount(accountUuid: String): LegacyAccount? =
        accountsState.value.find { it.uuid == accountUuid }

    override fun getAccountFlow(accountUuid: String): Flow<LegacyAccount?> =
        accountsState.map { list -> list.find { it.uuid == accountUuid } }

    override fun moveAccount(account: LegacyAccount, newPosition: Int) {
        // no-op for tests
    }

    override fun saveAccount(account: LegacyAccount) {
        // no-op for tests
    }
}

@OptIn(ExperimentalTime::class)
private class FakeClock(var nowInstant: Instant) : Clock {
    override fun now(): Instant = nowInstant
    fun advanceBy(duration: Duration) {
        nowInstant += duration
    }
}
+2 −0
Original line number Original line Diff line number Diff line
@@ -13,6 +13,7 @@ import com.fsck.k9.crypto.EncryptionExtractor
import com.fsck.k9.legacyCoreModules
import com.fsck.k9.legacyCoreModules
import com.fsck.k9.preferences.K9StoragePersister
import com.fsck.k9.preferences.K9StoragePersister
import net.thunderbird.core.android.account.AccountDefaultsProvider
import net.thunderbird.core.android.account.AccountDefaultsProvider
import net.thunderbird.core.android.account.LegacyAccountManager
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlagProvider
import net.thunderbird.core.featureflag.FeatureFlagProvider
import net.thunderbird.core.featureflag.InMemoryFeatureFlagProvider
import net.thunderbird.core.featureflag.InMemoryFeatureFlagProvider
@@ -81,4 +82,5 @@ val testModule = module {
            },
            },
        )
        )
    }
    }
    single<LegacyAccountManager> { mock() }
}
}
+2 −0
Original line number Original line Diff line number Diff line
@@ -5,6 +5,7 @@ import app.k9mail.feature.telemetry.telemetryModule
import app.k9mail.legacy.di.DI
import app.k9mail.legacy.di.DI
import com.fsck.k9.contacts.ContactPictureLoader
import com.fsck.k9.contacts.ContactPictureLoader
import net.thunderbird.core.android.account.AccountDefaultsProvider
import net.thunderbird.core.android.account.AccountDefaultsProvider
import net.thunderbird.core.android.account.LegacyAccountManager
import net.thunderbird.core.android.preferences.TestStoragePersister
import net.thunderbird.core.android.preferences.TestStoragePersister
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlagProvider
import net.thunderbird.core.featureflag.FeatureFlagProvider
@@ -79,4 +80,5 @@ val testModule = module {
    }
    }


    single<ContactPictureLoader> { mock() }
    single<ContactPictureLoader> { mock() }
    single<LegacyAccountManager> { mock() }
}
}