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

Unverified Commit 9c9dca56 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

test: add InstallHelper unit tests for reconciliation and enqueue flows

parent f8811842
Loading
Loading
Loading
Loading
+298 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.install.workmanager

import android.content.Context
import android.content.ContextWrapper
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.test.core.app.ApplicationProvider
import com.google.common.util.concurrent.Futures
import foundation.e.apps.data.enums.Status
import foundation.e.apps.data.install.AppInstallDAO
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.util.MainCoroutineRule
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify as verifyMockito
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.concurrent.TimeUnit

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.N])
class InstallHelperTest {

    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

    private val context: Context = ApplicationProvider.getApplicationContext()
    private val installDao = mock<AppInstallDAO>()
    private val appManagerWrapper = mock<AppManagerWrapper>()

    private var isInstallWorkManagerMocked = false

    @Before
    fun setup() {
        WorkManagerTestInitHelper.initializeTestWorkManager(context)
    }

    @After
    fun teardown() {
        if (isInstallWorkManagerMocked) {
            unmockkObject(InstallWorkManager)
            isInstallWorkManagerMocked = false
        }
    }

    @Test
    fun init_marksStaleDownloadAsInstallationIssue() = runTest {
        val app = createAppInstall(status = Status.DOWNLOADING)

        whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
        whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(false)

        val helper = InstallHelper(context, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verifyMockito(appManagerWrapper).installationIssue(app)
        verifyMockito(appManagerWrapper, never()).updateDownloadStatus(any(), any())
    }

    @Test
    fun init_updatesStatusToInstalledWhenAppAlreadyInstalled() = runTest {
        val app = createAppInstall(status = Status.DOWNLOADED)

        whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
        whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(true)

        val helper = InstallHelper(context, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verifyMockito(appManagerWrapper).updateDownloadStatus(app, Status.INSTALLED)
        verifyMockito(appManagerWrapper, never()).installationIssue(any())
    }

    @Test
    fun init_skipsReconciliationWhenInstallSessionIsActive() = runTest {
        val app = createAppInstall(status = Status.INSTALLING)
        val packageManager = mock<PackageManager>()
        val packageInstaller = mock<PackageInstaller>()
        val sessionInfo = mock<PackageInstaller.SessionInfo>()
        val wrappedContext = object : ContextWrapper(context) {
            override fun getPackageManager(): PackageManager = packageManager
        }

        whenever(sessionInfo.appPackageName).thenReturn(app.packageName)
        whenever(sessionInfo.isActive).thenReturn(true)
        whenever(packageManager.packageInstaller).thenReturn(packageInstaller)
        whenever(packageInstaller.allSessions).thenReturn(listOf(sessionInfo))
        whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())

        val helper = InstallHelper(wrappedContext, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verifyMockito(appManagerWrapper, never()).installationIssue(any())
        verifyMockito(appManagerWrapper, never()).updateDownloadStatus(any(), any())
    }

    @Test
    fun init_triggersAwaitingDownloadWhenNoActiveDownloadOrInstall() = runTest {
        val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING)
        mockInstallWorkManagerSuccess()

        whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))

        val helper = InstallHelper(context, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verify(exactly = 1) { InstallWorkManager.enqueueWork(awaiting, false) }
    }

    @Test
    fun init_doesNotTriggerAwaitingWhenThereIsAnActiveDownload() = runTest {
        val active = createAppInstall(id = "app.active", status = Status.DOWNLOADING)
        val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING)
        mockInstallWorkManagerSuccess()

        whenever(installDao.getDownloads())
            .thenReturn(flowOf(emptyList()), flowOf(listOf(active, awaiting)))

        val helper = InstallHelper(context, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any()) }
    }

    @Test
    fun init_reportsInstallationIssueWhenEnqueueFails() = runTest {
        val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING)
        mockInstallWorkManagerFailure()

        whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))

        val helper = InstallHelper(context, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verifyMockito(appManagerWrapper, times(1)).installationIssue(eq(awaiting))
    }

    @Test
    fun init_skipsReconciliationWhenActiveWorkExists() = runTest {
        val app = createAppInstall(id = "app.active.work", status = Status.DOWNLOADING)
        val request = OneTimeWorkRequestBuilder<NoOpWorker>()
            .setInitialDelay(1, TimeUnit.HOURS)
            .addTag(app.id)
            .build()
        WorkManager.getInstance(context).enqueue(request).result.get()

        whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())

        val helper = InstallHelper(context, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verifyMockito(appManagerWrapper, never()).installationIssue(any())
        verifyMockito(appManagerWrapper, never()).updateDownloadStatus(any(), any())
        verifyMockito(appManagerWrapper, never()).isFusedDownloadInstalled(any())
    }

    @Test
    fun init_doesNotReportIssueWhenEnqueueSucceeds() = runTest {
        val awaiting = createAppInstall(id = "app.awaiting.success", status = Status.AWAITING)
        mockInstallWorkManagerSuccess()

        whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))

        val helper = InstallHelper(context, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verifyMockito(appManagerWrapper, never()).installationIssue(any())
    }

    @Test
    fun init_continuesToObserveWhenReconciliationThrowsException() = runTest {
        val awaiting = createAppInstall(id = "app.awaiting.after.exception", status = Status.AWAITING)
        mockInstallWorkManagerSuccess()

        whenever(installDao.getDownloads())
            .thenThrow(RuntimeException("reconcile failed"))
            .thenReturn(flowOf(listOf(awaiting)))

        val helper = InstallHelper(context, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verify(exactly = 1) { InstallWorkManager.enqueueWork(awaiting, false) }
    }

    @Test
    fun init_stopsAfterCancellationExceptionDuringReconciliation() = runTest {
        whenever(installDao.getDownloads()).thenThrow(CancellationException("cancel reconcile"))

        val helper = InstallHelper(context, this, appManagerWrapper, installDao)

        helper.init()
        advanceUntilIdle()

        verifyMockito(installDao, times(1)).getDownloads()
    }

    private fun mockInstallWorkManagerSuccess() {
        mockkObject(InstallWorkManager)
        isInstallWorkManagerMocked = true
        every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() }
        every { InstallWorkManager.enqueueWork(any(), any()) } returns successfulOperation()
    }

    private fun mockInstallWorkManagerFailure() {
        mockkObject(InstallWorkManager)
        isInstallWorkManagerMocked = true
        every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() }
        every { InstallWorkManager.enqueueWork(any(), any()) } throws RuntimeException("enqueue failed")
    }

    private fun successfulOperation(): Operation {
        val operation = mock<Operation>()
        whenever(operation.result).thenReturn(Futures.immediateFuture(Operation.SUCCESS))
        return operation
    }

    private fun createAppInstall(
        id: String = "app.id",
        packageName: String = "foundation.e.app",
        status: Status,
    ) = AppInstall(
        id = id,
        name = id,
        packageName = packageName,
        status = status
    )

    class NoOpWorker(
        appContext: Context,
        workerParams: WorkerParameters
    ) : Worker(appContext, workerParams) {
        override fun doWork(): Result = Result.success()
    }
}