diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt index 4061051..3c24451 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/storage/migration/MigrationToLatestInstrumentedSharedTest.kt @@ -15,6 +15,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.koin.core.context.stopKoin +import org.koin.test.KoinTest import java.io.IOException /** @@ -23,7 +24,7 @@ import java.io.IOException * https://developer.android.com/training/data-storage/room/migrating-db-versions */ @RunWith(AndroidJUnit4::class) -open class MigrationToLatestInstrumentedSharedTest { +open class MigrationToLatestInstrumentedSharedTest : KoinTest { @get:Rule val helper = SharedMigrationTestRule(instrumentation = InstrumentationRegistry.getInstrumentation()) diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt index 1678675..798c9e7 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/home/MainActivityInstrumentedSharedTest.kt @@ -2,7 +2,6 @@ package org.fnives.test.showcase.ui.home import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents -import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule import org.fnives.test.showcase.android.testutil.activity.safeClose import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule @@ -21,11 +20,9 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain -import org.junit.runner.RunWith import org.koin.test.KoinTest @Suppress("TestFunctionName") -@RunWith(AndroidJUnit4::class) open class MainActivityInstrumentedSharedTest : KoinTest { private lateinit var activityScenario: ActivityScenario diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt index d838949..36725e5 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/AuthActivityInstrumentedSharedTest.kt @@ -2,7 +2,6 @@ package org.fnives.test.showcase.ui.login import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents -import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fnives.test.showcase.R import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule @@ -16,11 +15,9 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain -import org.junit.runner.RunWith import org.koin.test.KoinTest @Suppress("TestFunctionName") -@RunWith(AndroidJUnit4::class) open class AuthActivityInstrumentedSharedTest : KoinTest { private lateinit var activityScenario: ActivityScenario diff --git a/app/build.gradle b/app/build.gradle index 7234e88..d18fe4a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -41,12 +41,8 @@ android { } sourceSets { - androidTest { -// assets.srcDirs += files("$projectDir/schemas".toString()) - } test { java.srcDirs += "src/robolectricTest/java" -// resources.srcDirs += files("$projectDir/schemas".toString()) } } diff --git a/build.gradle b/build.gradle index 40d384d..8318014 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ buildscript { ext.kotlin_version = "1.6.10" ext.detekt_version = "1.19.0" ext.navigation_version = "2.4.2" + ext.hilt_version = "2.40.5" repositories { mavenCentral() google() @@ -13,6 +14,7 @@ buildscript { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version" + classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" } } diff --git a/hilt/hilt-app-shared-test/.gitignore b/hilt/hilt-app-shared-test/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-app-shared-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-app-shared-test/build.gradle b/hilt/hilt-app-shared-test/build.gradle new file mode 100644 index 0000000..740e553 --- /dev/null +++ b/hilt/hilt-app-shared-test/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' +} + +android { + compileSdk 31 + + defaultConfig { + minSdk 21 + targetSdk 31 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + sourceSets { + main { + assets.srcDirs += files("$projectDir/../hilt-app/schemas".toString()) + resources.srcDirs += files("$projectDir/../hilt-app/schemas".toString()) + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +// since it itself contains the Test it doesn't have tests of it's own +disableTestTasks(this) + +dependencies { + implementation project(":hilt:hilt-app") + implementation project(':test-util-android') + implementation testFixtures(project(':hilt:hilt-core')) + implementation "com.google.dagger:hilt-android-testing:$hilt_version" + implementation project(':test-util-shared-robolectric') + api project(':hilt:hilt-network-di-test-util') + applyAppSharedTestDependenciesTo(this) +} \ No newline at end of file diff --git a/hilt/hilt-app-shared-test/consumer-rules.pro b/hilt/hilt-app-shared-test/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-app-shared-test/proguard-rules.pro b/hilt/hilt-app-shared-test/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-app-shared-test/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-app-shared-test/src/main/AndroidManifest.xml b/hilt/hilt-app-shared-test/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4053ef5 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/di/TestBaseUrlHolder.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/di/TestBaseUrlHolder.kt new file mode 100644 index 0000000..f95f1dc --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/di/TestBaseUrlHolder.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.test.shared.di + +import org.fnives.test.showcase.hilt.BuildConfig + +object TestBaseUrlHolder { + + var url = BuildConfig.BASE_URL +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt new file mode 100644 index 0000000..da60724 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/storage/migration/MigrationToLatestInstrumentedSharedTest.kt @@ -0,0 +1,79 @@ +package org.fnives.test.showcase.hilt.test.shared.storage.migration + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.android.testutil.SharedMigrationTestRule +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.storage.favourite.FavouriteEntity +import org.fnives.test.showcase.hilt.storage.migation.Migration1To2 +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +/** + * reference: + * https://medium.com/androiddevelopers/testing-room-migrations-be93cdb0d975 + * https://developer.android.com/training/data-storage/room/migrating-db-versions + */ +@RunWith(AndroidJUnit4::class) +open class MigrationToLatestInstrumentedSharedTest { + + @get:Rule + val helper = SharedMigrationTestRule(instrumentation = InstrumentationRegistry.getInstrumentation()) + + private fun getMigratedRoomDatabase(): LocalDatabase { + val database: LocalDatabase = Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + LocalDatabase::class.java, + TEST_DB + ) + .addMigrations(Migration1To2()) + .build() + // close the database and release any stream resources when the test finishes + helper.closeWhenFinished(database) + return database + } + + @Test + @Throws(IOException::class) + open fun migrate1To2() { + val expectedEntities = setOf( + FavouriteEntity("123"), + FavouriteEntity("124"), + FavouriteEntity("125") + ) + val version1DB = helper.createDatabase( + name = TEST_DB, + version = 1 + ) + version1DB.run { + execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (\"123\")") + execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (124)") + execSQL("INSERT OR IGNORE INTO `FavouriteEntity` (`contentId`) VALUES (125)") + } + version1DB.close() + + val version2DB = helper.runMigrationsAndValidate( + name = TEST_DB, + version = 2, + validateDroppedTables = true, + Migration1To2() + ) + version2DB.close() + + val favouriteDao = getMigratedRoomDatabase().favouriteDao + + val entities = runBlocking { favouriteDao.get().first() }.toSet() + + Assert.assertEquals(expectedEntities, entities) + } + + companion object { + private const val TEST_DB = "migration-test" + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/MockServerScenarioSetupTestRule.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/MockServerScenarioSetupTestRule.kt new file mode 100644 index 0000000..ed48ae4 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/MockServerScenarioSetupTestRule.kt @@ -0,0 +1,37 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils + +import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate +import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class MockServerScenarioSetupTestRule : TestRule { + + lateinit var mockServerScenarioSetup: MockServerScenarioSetup + + override fun apply(base: Statement, description: Description): Statement = createStatement(base) + + private fun createStatement(base: Statement) = object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + before() + try { + base.evaluate() + } finally { + after() + } + } + } + + private fun before() { + val (mockServerScenarioSetup, url) = HttpsConfigurationModuleTemplate.startWithHTTPSMockWebServer() + TestBaseUrlHolder.url = url + this.mockServerScenarioSetup = mockServerScenarioSetup + } + + private fun after() { + mockServerScenarioSetup.stop() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/AsyncDiffUtilInstantTestRule.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/AsyncDiffUtilInstantTestRule.kt new file mode 100644 index 0000000..06bd99f --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/AsyncDiffUtilInstantTestRule.kt @@ -0,0 +1,32 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.idling + +import org.fnives.test.showcase.hilt.ui.shared.executor.AsyncTaskExecutor +import org.fnives.test.showcase.hilt.ui.shared.executor.TaskExecutor +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Similar Test Rule to InstantTaskExecutorRule just for the [AsyncTaskExecutor] to make AsyncDiffUtil synchronized. + */ +class AsyncDiffUtilInstantTestRule : TestRule { + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + AsyncTaskExecutor.delegate = object : TaskExecutor { + override fun executeOnDiskIO(runnable: Runnable) { + runnable.run() + } + + override fun postToMainThread(runnable: Runnable) { + runnable.run() + } + } + + base.evaluate() + + AsyncTaskExecutor.delegate = null + } + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/DatabaseDispatcherTestRule.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/DatabaseDispatcherTestRule.kt new file mode 100644 index 0000000..9d98b31 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/DatabaseDispatcherTestRule.kt @@ -0,0 +1,52 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.idling + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.anyResourceNotIdle +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.awaitIdlingResources +import org.fnives.test.showcase.android.testutil.synchronization.runOnUIAwaitOnCurrent +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +@OptIn(ExperimentalCoroutinesApi::class) +class DatabaseDispatcherTestRule : TestRule { + + lateinit var testDispatcher: TestDispatcher + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + val dispatcher = StandardTestDispatcher() + testDispatcher = dispatcher + TestDatabaseInitialization.dispatcher = dispatcher + base.evaluate() + } + } + + fun advanceUntilIdleWithIdlingResources() = runOnUIAwaitOnCurrent { + testDispatcher.advanceUntilIdleWithIdlingResources() + } + + fun advanceUntilIdle() = runOnUIAwaitOnCurrent { + testDispatcher.scheduler.advanceUntilIdle() + } + + fun advanceTimeBy(delayInMillis: Long) = runOnUIAwaitOnCurrent { + testDispatcher.scheduler.advanceTimeBy(delayInMillis) + } + + companion object { + fun TestDispatcher.advanceUntilIdleWithIdlingResources() { + scheduler.advanceUntilIdle() // advance until a request is sent + while (anyResourceNotIdle()) { // check if any request is in progress + awaitIdlingResources() // complete all requests and other idling resources + scheduler.advanceUntilIdle() // run coroutines after request is finished + } + scheduler.advanceUntilIdle() + } + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/MainDispatcherTestRule.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/MainDispatcherTestRule.kt new file mode 100644 index 0000000..7e1e4b3 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/MainDispatcherTestRule.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.idling + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import org.fnives.test.showcase.android.testutil.synchronization.MainDispatcherTestRule as LibMainDispatcherTestRule + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherTestRule(useStandard: Boolean = true) : LibMainDispatcherTestRule(useStandard) { + + override fun onTestDispatcherInitialized(testDispatcher: TestDispatcher) { + TestDatabaseInitialization.dispatcher = testDispatcher + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/NetworkSynchronizationHelper.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/NetworkSynchronizationHelper.kt new file mode 100644 index 0000000..8ecb76f --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/idling/NetworkSynchronizationHelper.kt @@ -0,0 +1,23 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.idling + +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable +import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization +import javax.inject.Inject + +class NetworkSynchronizationHelper @Inject constructor(private val networkSynchronization: NetworkSynchronization) { + + private val compositeDisposable = CompositeDisposable() + + fun setup() { + networkSynchronization.networkIdlingResources().map { + IdlingResourceDisposable(it) + }.forEach { + compositeDisposable.add(it) + } + } + + fun dispose() { + compositeDisposable.dispose() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/statesetup/SetupAuthenticationState.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/statesetup/SetupAuthenticationState.kt new file mode 100644 index 0000000..2479e25 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/statesetup/SetupAuthenticationState.kt @@ -0,0 +1,69 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.statesetup + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import androidx.test.runner.intent.IntentStubberRegistry +import org.fnives.test.showcase.android.testutil.activity.safeClose +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.ui.auth.LoginRobot +import org.fnives.test.showcase.hilt.test.shared.ui.home.HomeRobot +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.hilt.ui.home.MainActivity +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.koin.test.KoinTest + +object SetupAuthenticationState : KoinTest { + + fun setupLogin( + mainDispatcherTestRule: MainDispatcherTestRule, + mockServerScenarioSetup: MockServerScenarioSetup, + resetIntents: Boolean = true, + ) { + resetIntentsIfNeeded(resetIntents) { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b")) + val activityScenario = ActivityScenario.launch(AuthActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + val loginRobot = LoginRobot() + loginRobot.setupIntentResults() + loginRobot + .setPassword("b") + .setUsername("a") + .clickOnLogin() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + activityScenario.safeClose() + } + } + + fun setupLogout( + mainDispatcherTestRule: MainDispatcherTestRule, + resetIntents: Boolean = true, + ) { + resetIntentsIfNeeded(resetIntents) { + val activityScenario = ActivityScenario.launch(MainActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + val homeRobot = HomeRobot() + homeRobot.setupIntentResults() + homeRobot.clickSignOut() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + activityScenario.safeClose() + } + } + + private fun resetIntentsIfNeeded(resetIntents: Boolean, action: () -> Unit) { + val wasInitialized = IntentStubberRegistry.isLoaded() + if (!wasInitialized) { + Intents.init() + } + action() + Intents.release() + if (resetIntents && wasInitialized) { + Intents.init() + } + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/storage/TestDatabaseInitialization.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/storage/TestDatabaseInitialization.kt new file mode 100644 index 0000000..9a04896 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/testutils/storage/TestDatabaseInitialization.kt @@ -0,0 +1,48 @@ +package org.fnives.test.showcase.hilt.test.shared.testutils.storage + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor +import org.fnives.test.showcase.hilt.di.StorageModule +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import javax.inject.Singleton + +/** + * Reloads the Database module, so it uses the inMemory database with the switched out Executors. + * + * This is needed so in AndroidTests not a real File based device is used. + * This speeds tests up, and isolates them better, there will be no junk in the Database file from previous tests. + */ +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [StorageModule::class] +) +object TestDatabaseInitialization { + + var dispatcher: CoroutineDispatcher? = null + + @Suppress("ObjectPropertyName") + private val _dispatcher: CoroutineDispatcher + get() = dispatcher ?: throw IllegalStateException("TestDispatcher is not initialized") + + fun create(context: Context, dispatcher: CoroutineDispatcher = this._dispatcher): LocalDatabase { + val executor = dispatcher.asExecutor() + return Room.inMemoryDatabaseBuilder(context, LocalDatabase::class.java) + .setTransactionExecutor(executor) + .setQueryExecutor(executor) + .allowMainThreadQueries() + .build() + } + + @Singleton + @Provides + fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase = + create(context) +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt new file mode 100644 index 0000000..ac61133 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt @@ -0,0 +1,41 @@ +package org.fnives.test.showcase.hilt.test.shared.ui + +import dagger.hilt.android.testing.HiltAndroidRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.NetworkSynchronizationHelper +import org.junit.After +import org.junit.Before +import org.junit.Rule +import javax.inject.Inject + +open class NetworkSynchronizedActivityTest { + + @Inject + lateinit var networkSynchronizationHelper: NetworkSynchronizationHelper + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Before + fun setup() { + setupBeforeInjection() + + hiltRule.inject() + networkSynchronizationHelper.setup() + setupAfterInjection() + } + + open fun setupBeforeInjection() { + } + + open fun setupAfterInjection() { + } + + @After + fun tearDown() { + networkSynchronizationHelper.dispose() + additionalTearDown() + } + + open fun additionalTearDown() { + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt new file mode 100644 index 0000000..f9b7327 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/AuthActivityInstrumentedSharedTest.kt @@ -0,0 +1,138 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.auth + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule +import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule +import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +@Suppress("TestFunctionName") +open class AuthActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { + + private lateinit var activityScenario: ActivityScenario + + private val mainDispatcherTestRule = MainDispatcherTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup + private lateinit var robot: LoginRobot + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) + .around(mockServerScenarioSetupTestRule) + .around(mainDispatcherTestRule) + .around(SafeCloseActivityRule { activityScenario }) + .around(ScreenshotRule("test-showcase")) + + override fun setupAfterInjection() { + Intents.init() + robot = LoginRobot() + } + + override fun additionalTearDown() { + Intents.release() + } + + /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ + @Test + fun properLoginResultsInNavigationToHome() { + mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan") + ) + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setPassword("alma") + .setUsername("banan") + .assertPassword("alma") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertNavigatedToHome() + } + + /** GIVEN empty password and username WHEN signIn THEN error password is shown */ + @Test + fun emptyPasswordShowsProperErrorMessage() { + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and empty username WHEN signIn THEN error username is shown */ + @Test + fun emptyUserNameShowsProperErrorMessage() { + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun invalidCredentialsGivenShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(username = "alma", password = "banan") + ) + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun networkErrorShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan") + ) + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotNavigatedToHome() + .assertNotLoading() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/LoginRobot.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/LoginRobot.kt new file mode 100644 index 0000000..c42bd39 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/auth/LoginRobot.kt @@ -0,0 +1,94 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.auth + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.annotation.StringRes +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.fnives.test.showcase.android.testutil.intent.notIntended +import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsNotShown +import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsShownWithText +import org.fnives.test.showcase.android.testutil.viewaction.progressbar.ReplaceProgressBarDrawableToStatic +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.ui.home.MainActivity +import org.hamcrest.core.IsNot.not + +class LoginRobot { + + fun setupIntentResults() { + Intents.intending(hasComponent(MainActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + /** + * Needed because Espresso idling waits until mainThread is idle. + * + * However, ProgressBar keeps the main thread active since it's animating. + * + * Another solution is described here: https://proandroiddev.com/progressbar-animations-with-espresso-57f826102187 + * In short they replace the inflater to remove animations, by using custom test runner. + */ + fun replaceProgressBar() = apply { + onView(withId(R.id.loading_indicator)).perform(ReplaceProgressBarDrawableToStatic()) + } + + fun setUsername(username: String): LoginRobot = apply { + onView(withId(R.id.user_edit_text)) + .perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard()) + } + + fun setPassword(password: String): LoginRobot = apply { + onView(withId(R.id.password_edit_text)) + .perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard()) + } + + fun clickOnLogin() = apply { + replaceProgressBar() + onView(withId(R.id.login_cta)) + .perform(ViewActions.click()) + } + + fun assertPassword(password: String) = apply { + onView(withId((R.id.password_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(password))) + } + + fun assertUsername(username: String) = apply { + onView(withId((R.id.user_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(username))) + } + + fun assertErrorIsShown(@StringRes stringResID: Int) = apply { + assertSnackBarIsShownWithText(stringResID) + } + + fun assertLoadingBeforeRequests() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(isDisplayed())) + } + + fun assertNotLoading() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(not(isDisplayed()))) + } + + fun assertErrorIsNotShown() = apply { + assertSnackBarIsNotShown() + } + + fun assertNavigatedToHome() = apply { + intended(hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertNotNavigatedToHome() = apply { + notIntended(hasComponent(MainActivity::class.java.canonicalName)) + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/HomeRobot.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/HomeRobot.kt new file mode 100644 index 0000000..a0a024f --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/HomeRobot.kt @@ -0,0 +1,123 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.home + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasChildCount +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withChild +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withParent +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.fnives.test.showcase.android.testutil.intent.notIntended +import org.fnives.test.showcase.android.testutil.viewaction.imageview.WithDrawable +import org.fnives.test.showcase.android.testutil.viewaction.recycler.RemoveItemAnimations +import org.fnives.test.showcase.android.testutil.viewaction.swiperefresh.PullToRefresh +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.FavouriteContent +import org.hamcrest.Matchers.allOf + +class HomeRobot { + + /** + * Needed because Espresso idling sometimes not in sync with RecyclerView's animation. + * So we simply remove the item animations, the animations should be disabled anyway for test. + * + * Reference: https://github.com/android/android-test/issues/223 + */ + fun removeItemAnimations() = apply { + Espresso.onView(withId(R.id.recycler)).perform(RemoveItemAnimations()) + } + + fun assertToolbarIsShown() = apply { + Espresso.onView(withId(R.id.toolbar)) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + } + + fun setupIntentResults() { + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + fun assertNavigatedToAuth() = apply { + Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun assertDidNotNavigateToAuth() = apply { + notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun clickSignOut(setupIntentResults: Boolean = true) = apply { + if (setupIntentResults) { + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + Espresso.onView(withId(R.id.logout_cta)).perform(click()) + } + + fun assertContainsItem(index: Int, item: FavouriteContent) = apply { + removeItemAnimations() + val isFavouriteResourceId = if (item.isFavourite) { + R.drawable.favorite_24 + } else { + R.drawable.favorite_border_24 + } + Espresso.onView(withId(R.id.recycler)) + .perform(RecyclerViewActions.scrollToPosition(index)) + + Espresso.onView( + allOf( + withChild(allOf(withText(item.content.title), withId(R.id.title))), + withChild(allOf(withText(item.content.description), withId(R.id.description))), + withChild(allOf(withId(R.id.favourite_cta), WithDrawable(isFavouriteResourceId))) + ) + ) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + } + + fun clickOnContentItem(index: Int, item: Content) = apply { + removeItemAnimations() + Espresso.onView(withId(R.id.recycler)) + .perform(RecyclerViewActions.scrollToPosition(index)) + + Espresso.onView( + allOf( + withId(R.id.favourite_cta), + withParent( + allOf( + withChild(allOf(withText(item.title), withId(R.id.title))), + withChild(allOf(withText(item.description), withId(R.id.description))) + ) + ) + ) + ) + .perform(click()) + } + + fun swipeRefresh() = apply { + Espresso.onView(withId(R.id.swipe_refresh_layout)).perform(PullToRefresh()) + } + + fun assertContainsNoItems() = apply { + removeItemAnimations() + Espresso.onView(withId(R.id.recycler)) + .check(matches(hasChildCount(0))) + } + + fun assertContainsError() = apply { + Espresso.onView(withId(R.id.error_message)) + .check(matches(allOf(isDisplayed(), withText(R.string.something_went_wrong)))) + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt new file mode 100644 index 0000000..128f468 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/home/MainActivityInstrumentedSharedTest.kt @@ -0,0 +1,214 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.home + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule +import org.fnives.test.showcase.android.testutil.activity.safeClose +import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule +import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule +import org.fnives.test.showcase.android.testutil.synchronization.loopMainThreadFor +import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.AsyncDiffUtilInstantTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin +import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.hilt.ui.home.MainActivity +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +@Suppress("TestFunctionName") +open class MainActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { + + private lateinit var activityScenario: ActivityScenario + + private val mainDispatcherTestRule = MainDispatcherTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup + private lateinit var robot: HomeRobot + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) + .around(mockServerScenarioSetupTestRule) + .around(mainDispatcherTestRule) + .around(AsyncDiffUtilInstantTestRule()) + .around(SafeCloseActivityRule { activityScenario }) + .around(ScreenshotRule("test-showcase")) + + override fun setupAfterInjection() { + super.setupAfterInjection() + robot = HomeRobot() + setupLogin(mainDispatcherTestRule, mockServerScenarioSetup) + Intents.init() + } + + override fun additionalTearDown() { + super.additionalTearDown() + Intents.release() + } + + /** GIVEN initialized MainActivity WHEN signout is clicked THEN user is signed out */ + @Test + fun signOutClickedResultsInNavigation() { + mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.clickSignOut() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.assertNavigatedToAuth() + } + + /** GIVEN success response WHEN data is returned THEN it is shown on the ui */ + @Test + fun successfulDataLoadingShowsTheElementsOnTheUI() { + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + ContentData.contentSuccess.forEachIndexed { index, content -> + robot.assertContainsItem(index, FavouriteContent(content, false)) + } + robot.assertDidNotNavigateToAuth() + } + + /** GIVEN success response WHEN item is clicked THEN ui is updated */ + @Test + fun clickingOnListElementUpdatesTheElementsFavouriteState() { + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.clickOnContentItem(0, ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true) + robot.assertContainsItem(0, expectedItem) + .assertDidNotNavigateToAuth() + } + + /** GIVEN success response WHEN item is clicked THEN ui is updated even if activity is recreated */ + @Test + fun elementFavouritedIsKeptEvenIfActivityIsRecreated() { + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.clickOnContentItem(0, ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true) + + activityScenario.safeClose() + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.assertContainsItem(0, expectedItem) + .assertDidNotNavigateToAuth() + } + + /** GIVEN success response WHEN item is clicked then clicked again THEN ui is updated */ + @Test + fun clickingAnElementMultipleTimesProperlyUpdatesIt() { + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.clickOnContentItem(0, ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + robot.clickOnContentItem(0, ContentData.contentSuccess.first()) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), false) + robot.assertContainsItem(0, expectedItem) + .assertDidNotNavigateToAuth() + } + + /** GIVEN error response WHEN loaded THEN error is Shown */ + @Test + fun networkErrorResultsInUIErrorStateShown() { + mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false)) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.assertContainsNoItems() + .assertContainsError() + .assertDidNotNavigateToAuth() + } + + /** GIVEN error response then success WHEN retried THEN success is shown */ + @Test + fun retryingFromErrorStateAndSucceedingShowsTheData() { + mockServerScenarioSetup.setScenario( + ContentScenario.Error(usingRefreshedToken = false) + .then(ContentScenario.Success(usingRefreshedToken = false)) + ) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.swipeRefresh() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + loopMainThreadFor(2000L) + + ContentData.contentSuccess.forEachIndexed { index, content -> + robot.assertContainsItem(index, FavouriteContent(content, false)) + } + robot.assertDidNotNavigateToAuth() + } + + /** GIVEN success then error WHEN retried THEN error is shown */ + @Test + fun errorIsShownIfTheDataIsFetchedAndErrorIsReceived() { + mockServerScenarioSetup.setScenario( + ContentScenario.Success(usingRefreshedToken = false) + .then(ContentScenario.Error(usingRefreshedToken = false)) + ) + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.swipeRefresh() + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot + .assertContainsError() + .assertContainsNoItems() + .assertDidNotNavigateToAuth() + } + + /** GIVEN unauthenticated then success WHEN loaded THEN success is shown */ + @Test + fun authenticationIsHandledWithASingleLoading() { + mockServerScenarioSetup.setScenario( + ContentScenario.Unauthorized(usingRefreshedToken = false) + .then(ContentScenario.Success(usingRefreshedToken = true)) + ) + .setScenario(RefreshTokenScenario.Success) + + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + ContentData.contentSuccess.forEachIndexed { index, content -> + robot.assertContainsItem(index, FavouriteContent(content, false)) + } + robot.assertDidNotNavigateToAuth() + } + + /** GIVEN unauthenticated then error WHEN loaded THEN navigated to auth */ + @Test + fun sessionExpirationResultsInNavigation() { + mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false)) + .setScenario(RefreshTokenScenario.Error) + + activityScenario = ActivityScenario.launch(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + robot.assertNavigatedToAuth() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt new file mode 100644 index 0000000..aeadbaa --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashActivityInstrumentedSharedTest.kt @@ -0,0 +1,101 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.splash + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import org.fnives.test.showcase.android.testutil.activity.SafeCloseActivityRule +import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule +import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule +import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.MainDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogin +import org.fnives.test.showcase.hilt.test.shared.testutils.statesetup.SetupAuthenticationState.setupLogout +import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.hilt.ui.splash.SplashActivity +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +@Suppress("TestFunctionName") +open class SplashActivityInstrumentedSharedTest : NetworkSynchronizedActivityTest() { + + private lateinit var activityScenario: ActivityScenario + + private val mainDispatcherTestRule = MainDispatcherTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + private val mockServerScenarioSetup: MockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup + + private lateinit var robot: SplashRobot + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) + .around(mainDispatcherTestRule) + .around(mockServerScenarioSetupTestRule) + .around(SafeCloseActivityRule { activityScenario }) + .around(ScreenshotRule("test-showcase")) + + override fun setupAfterInjection() { + Intents.init() + robot = SplashRobot() + } + + override fun additionalTearDown() { + Intents.release() + } + + /** GIVEN loggedInState WHEN opened after some time THEN MainActivity is started */ + @Test + fun loggedInStateNavigatesToHome() { + setupLogin(mainDispatcherTestRule, mockServerScenarioSetup) + + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(501) + + robot.assertHomeIsStarted() + .assertAuthIsNotStarted() + } + + /** GIVEN loggedOffState WHEN opened after some time THEN AuthActivity is started */ + @Test + fun loggedOutStatesNavigatesToAuthentication() { + setupLogout(mainDispatcherTestRule) + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(501) + + robot.assertAuthIsStarted() + .assertHomeIsNotStarted() + } + + /** GIVEN loggedOffState and not enough time WHEN opened THEN no activity is started */ + @Test + fun loggedOutStatesNotEnoughTime() { + setupLogout(mainDispatcherTestRule) + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(500) + + robot.assertAuthIsNotStarted() + .assertHomeIsNotStarted() + } + + /** GIVEN loggedInState and not enough time WHEN opened THEN no activity is started */ + @Test + fun loggedInStatesNotEnoughTime() { + setupLogin(mainDispatcherTestRule, mockServerScenarioSetup) + + activityScenario = ActivityScenario.launch(SplashActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + + mainDispatcherTestRule.advanceTimeBy(500) + + robot.assertHomeIsNotStarted() + .assertAuthIsNotStarted() + } +} diff --git a/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashRobot.kt b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashRobot.kt new file mode 100644 index 0000000..9cb0a27 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/splash/SplashRobot.kt @@ -0,0 +1,36 @@ +package org.fnives.test.showcase.hilt.test.shared.ui.splash + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import org.fnives.test.showcase.android.testutil.intent.notIntended +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.hilt.ui.home.MainActivity + +class SplashRobot { + + fun setupIntentResults() { + Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + fun assertHomeIsStarted() = apply { + Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertHomeIsNotStarted() = apply { + notIntended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertAuthIsStarted() = apply { + Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun assertAuthIsNotStarted() = apply { + notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } +} diff --git a/hilt/hilt-app/.gitignore b/hilt/hilt-app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-app/build.gradle b/hilt/hilt-app/build.gradle new file mode 100644 index 0000000..1dd1fd2 --- /dev/null +++ b/hilt/hilt-app/build.gradle @@ -0,0 +1,127 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' +} + +android { + compileSdk 31 + + defaultConfig { + applicationId "org.fnives.test.showcase.hilt" + minSdk 21 + targetSdk 31 + versionCode 1 + versionName "1.0" + buildConfigField "String", "BASE_URL", '"https://606844a10add49001733fe6b.mockapi.io/"' + + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } + } + + testInstrumentationRunner "org.fnives.test.showcase.hilt.runner.HiltTestRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + flavorDimensions 'di' + + buildFeatures { + viewBinding true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = project.androidx_compose_version + } + + sourceSets { + test { + java.srcDirs += "src/robolectricTest/java" + } + } + + // needed for androidTest + packagingOptions { + exclude 'META-INF/LGPL2.1' + exclude 'META-INF/AL2.0' + exclude 'META-INF/LICENSE.md' + exclude 'META-INF/LICENSE-notice.md' + } +} + +hilt { + enableAggregatingTask = true +} + +afterEvaluate { + // making sure the :mockserver is assembled after :clean when running tests + testDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble') + testReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble') +} + +dependencies { + implementation "androidx.core:core-ktx:$androidx_core_version" + implementation "androidx.appcompat:appcompat:$androidx_appcompat_version" + implementation "com.google.android.material:material:$androidx_material_version" + implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_version" + implementation "androidx.constraintlayout:constraintlayout-compose:$androidx_compose_constraintlayout_version" + implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$androidx_livedata_version" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version" + + implementation "androidx.activity:activity-compose:$activity_ktx_version" + implementation "androidx.navigation:navigation-compose:$androidx_navigation" + + implementation "androidx.compose.ui:ui:$androidx_compose_version" + implementation "androidx.compose.ui:ui-tooling:$androidx_compose_version" + implementation "androidx.compose.foundation:foundation:$androidx_compose_version" + implementation "androidx.compose.material:material:$androidx_compose_version" + implementation "androidx.compose.animation:animation-graphics:$androidx_compose_version" + implementation "com.google.accompanist:accompanist-insets:$google_accompanist_version" + implementation "com.google.accompanist:accompanist-swiperefresh:$google_accompanist_version" + + // Hilt + implementation "com.google.dagger:hilt-android:$hilt_version" + kapt "com.google.dagger:hilt-compiler:$hilt_version" + + implementation "androidx.room:room-runtime:$room_version" + kapt "androidx.room:room-compiler:$room_version" + implementation "androidx.room:room-ktx:$room_version" + + implementation "io.coil-kt:coil:$coil_version" + implementation "io.coil-kt:coil-compose:$coil_version" + + implementation project(":hilt:hilt-core") + + applyAppTestDependenciesTo(this) + applyComposeTestDependenciesTo(this) + + androidTestImplementation project(':mockserver') + + testImplementation project(':test-util-junit5-android') + testImplementation project(':test-util-shared-robolectric') + testImplementation project(':test-util-android') + androidTestImplementation project(':test-util-android') + androidTestImplementation project(':test-util-shared-android') + + testImplementation testFixtures(project(":hilt:hilt-core")) + androidTestImplementation testFixtures(project(":hilt:hilt-core")) + + testImplementation project(':hilt:hilt-app-shared-test') + androidTestImplementation project(':hilt:hilt-app-shared-test') + + testImplementation "com.google.dagger:hilt-android-testing:$hilt_version" + kaptTest "com.google.dagger:hilt-compiler:$hilt_version" + androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" + kaptAndroidTest "com.google.dagger:hilt-compiler:$hilt_version" +} + +apply from: '../../gradlescripts/pull-screenshots.gradle' \ No newline at end of file diff --git a/hilt/hilt-app/consumer-rules.pro b/hilt/hilt-app/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-app/proguard-rules.pro b/hilt/hilt-app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/1.json b/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/1.json new file mode 100644 index 0000000..c482b98 --- /dev/null +++ b/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/1.json @@ -0,0 +1,34 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "36d840e89667f36e0c265593da36fe23", + "entities": [ + { + "tableName": "FavouriteEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contentId` TEXT NOT NULL, PRIMARY KEY(`contentId`))", + "fields": [ + { + "fieldPath": "contentId", + "columnName": "contentId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contentId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '36d840e89667f36e0c265593da36fe23')" + ] + } +} \ No newline at end of file diff --git a/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/2.json b/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/2.json new file mode 100644 index 0000000..de49ad7 --- /dev/null +++ b/hilt/hilt-app/schemas/org.fnives.test.showcase.hilt.storage.LocalDatabase/2.json @@ -0,0 +1,34 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "3723fe73a9d3dc43de8ff3e52ec46490", + "entities": [ + { + "tableName": "FavouriteEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`content_id` TEXT NOT NULL, PRIMARY KEY(`content_id`))", + "fields": [ + { + "fieldPath": "contentId", + "columnName": "content_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "content_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3723fe73a9d3dc43de8ff3e52ec46490')" + ] + } +} \ No newline at end of file diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt new file mode 100644 index 0000000..d90bfb7 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient +import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier +import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor +import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BindsBaseOkHttpClient::class] +) +object HttpsConfigurationModule { + + @Provides + @Singleton + @SessionLessQualifier + fun bindsBaseOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) = + HttpsConfigurationModuleTemplate.bindsBaseOkHttpClient(enableLogging, platformInterceptor) +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt new file mode 100644 index 0000000..cde8d0e --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BaseUrlModule::class] +) +object TestBaseUrlModule { + + @Provides + fun provideBaseUrl(): String = TestBaseUrlHolder.url +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt new file mode 100644 index 0000000..687707e --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.hilt.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [StorageModule::class] +) +object TestDatabaseInitializationModule { + + @Singleton + @Provides + fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase = + TestDatabaseInitialization.provideLocalDatabase(context) +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestUserDataLocalStorageModule.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestUserDataLocalStorageModule.kt new file mode 100644 index 0000000..3d1cc88 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/di/TestUserDataLocalStorageModule.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [UserDataLocalStorageModule::class] +) +object TestUserDataLocalStorageModule { + + var replacement: UserDataLocalStorage? = null + + @Singleton + @Provides + fun provideUserDataLocalStorage( + sharedPreferencesManagerImpl: SharedPreferencesManagerImpl, + ): UserDataLocalStorage = replacement ?: sharedPreferencesManagerImpl +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/runner/HiltTestRunner.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/runner/HiltTestRunner.kt new file mode 100644 index 0000000..6807109 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/runner/HiltTestRunner.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.runner + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application = + super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt new file mode 100644 index 0000000..45ce6d9 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.storage.migration + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.hilt.test.shared.storage.migration.MigrationToLatestInstrumentedSharedTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MigrationToLatestInstrumentedTest : MigrationToLatestInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt new file mode 100644 index 0000000..ebef811 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.auth.AuthActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AuthActivityInstrumentedTest : AuthActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt new file mode 100644 index 0000000..6ece289 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt @@ -0,0 +1,199 @@ +package org.fnives.test.showcase.hilt.ui.compose + +import androidx.compose.ui.test.MainTestClock +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.espresso.Espresso +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.android.testutil.intent.DismissSystemDialogsRule +import org.fnives.test.showcase.android.testutil.screenshot.ScreenshotRule +import org.fnives.test.showcase.android.testutil.viewaction.LoopMainThreadFor +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.compose.screen.AppNavigation +import org.fnives.test.showcase.hilt.core.integration.fake.FakeUserDataLocalStorage +import org.fnives.test.showcase.hilt.di.TestUserDataLocalStorageModule +import org.fnives.test.showcase.hilt.test.shared.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.hilt.test.shared.testutils.idling.DatabaseDispatcherTestRule +import org.fnives.test.showcase.hilt.test.shared.ui.NetworkSynchronizedActivityTest +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AuthComposeInstrumentedTest : NetworkSynchronizedActivityTest() { + + private val composeTestRule = createComposeRule() + private val stateRestorationTester = StateRestorationTester(composeTestRule) + + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule() + private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup + private val dispatcherTestRule = DatabaseDispatcherTestRule() + private lateinit var robot: ComposeLoginRobot + private lateinit var navigationRobot: ComposeNavigationRobot + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) + .around(mockServerScenarioSetupTestRule) + .around(dispatcherTestRule) + .around(composeTestRule) + .around(ScreenshotRule("test-showcase-compose")) + + override fun setupBeforeInjection() { + TestUserDataLocalStorageModule.replacement = FakeUserDataLocalStorage() + } + + override fun setupAfterInjection() { + stateRestorationTester.setContent { + AppNavigation() + } + robot = ComposeLoginRobot(composeTestRule) + navigationRobot = ComposeNavigationRobot(composeTestRule) + } + + /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ + @Test + fun properLoginResultsInNavigationToHome() { + mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan") + ) + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + + navigationRobot.assertAuthScreen() + robot.setPassword("alma") + .setUsername("banan") + .assertUsername("banan") + .assertPassword("alma") + + composeTestRule.mainClock.autoAdvance = false + robot.clickOnLogin() + composeTestRule.mainClock.advanceTimeByFrame() + robot.assertLoading() + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.mainClock.awaitIdlingResources() + navigationRobot.assertHomeScreen() + } + + /** GIVEN empty password and username WHEN signIn THEN error password is shown */ + @Test + fun emptyPasswordShowsProperErrorMessage() { + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + + robot.setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + + composeTestRule.mainClock.awaitIdlingResources() + robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN password and empty username WHEN signIn THEN error username is shown */ + @Test + fun emptyUserNameShowsProperErrorMessage() { + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + + robot + .setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + + composeTestRule.mainClock.awaitIdlingResources() + robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun invalidCredentialsGivenShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(password = "alma", username = "banan") + ) + + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + + composeTestRule.mainClock.autoAdvance = false + robot.clickOnLogin() + composeTestRule.mainClock.advanceTimeByFrame() + robot.assertLoading() + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.mainClock.awaitIdlingResources() + robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun networkErrorShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan") + ) + + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + + composeTestRule.mainClock.autoAdvance = false + robot.clickOnLogin() + composeTestRule.mainClock.advanceTimeByFrame() + robot.assertLoading() + composeTestRule.mainClock.autoAdvance = true + + composeTestRule.mainClock.awaitIdlingResources() + robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotLoading() + navigationRobot.assertAuthScreen() + } + + /** GIVEN username and password WHEN restoring THEN username and password fields contain the same text */ + @Test + fun restoringContentShowPreviousCredentials() { + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + robot.setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + + stateRestorationTester.emulateSavedInstanceStateRestore() + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) // ensure all time based operation run + + navigationRobot.assertAuthScreen() + robot.assertUsername("alma") + .assertPassword("banan") + } + + companion object { + private const val SPLASH_DELAY = 600L + + // workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 + /** + * Await the idling resource on a different thread while looping main. + */ + fun MainTestClock.awaitIdlingResources() { + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(100L)) + + advanceTimeByFrame() + } + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt new file mode 100644 index 0000000..913c350 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeLoginRobot.kt @@ -0,0 +1,52 @@ +package org.fnives.test.showcase.hilt.ui.compose + +import android.content.Context +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreenTag + +class ComposeLoginRobot( + composeTestRule: ComposeTestRule, +) : ComposeTestRule by composeTestRule { + + fun setUsername(username: String): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) + } + + fun setPassword(password: String): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password) + } + + fun assertPassword(password: String): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick() + onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password) + } + + fun assertUsername(username: String): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username) + } + + fun clickOnLogin(): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.LoginButton).performClick() + } + + fun assertLoading(): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed() + } + + fun assertNotLoading(): ComposeLoginRobot = apply { + onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0) + } + + fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply { + onNodeWithTag(AuthScreenTag.LoginError) + .assertTextContains(ApplicationProvider.getApplicationContext().resources.getString(stringId)) + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeNavigationRobot.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeNavigationRobot.kt new file mode 100644 index 0000000..d4ac4df --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/ComposeNavigationRobot.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.hilt.ui.compose + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import org.fnives.test.showcase.hilt.compose.screen.AppNavigationTag + +class ComposeNavigationRobot( + private val composeTestRule: ComposeTestRule, +) { + + fun assertHomeScreen(): ComposeNavigationRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists() + } + + fun assertAuthScreen(): ComposeNavigationRobot = apply { + composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists() + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt new file mode 100644 index 0000000..e71b267 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.home + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.home.MainActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class MainActivityInstrumentedTest : MainActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt new file mode 100644 index 0000000..74b6ace --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.splash.SplashActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class SplashActivityInstrumentedTest : SplashActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/main/AndroidManifest.xml b/hilt/hilt-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..77b5522 --- /dev/null +++ b/hilt/hilt-app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/TestShowcaseApplication.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/TestShowcaseApplication.kt new file mode 100644 index 0000000..038bce3 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/TestShowcaseApplication.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.hilt + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class TestShowcaseApplication : Application() diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/ComposeActivity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/ComposeActivity.kt new file mode 100644 index 0000000..211cf82 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/ComposeActivity.kt @@ -0,0 +1,30 @@ +package org.fnives.test.showcase.hilt.compose + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import com.google.accompanist.insets.ProvideWindowInsets +import dagger.hilt.android.AndroidEntryPoint +import org.fnives.test.showcase.hilt.compose.screen.AppNavigation + +@AndroidEntryPoint +class ComposeActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TestShowCaseApp() + } + } +} + +@Composable +fun TestShowCaseApp() { + ProvideWindowInsets { + MaterialTheme { + AppNavigation() + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt new file mode 100644 index 0000000..a383d08 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigation.kt @@ -0,0 +1,81 @@ +package org.fnives.test.showcase.hilt.compose.screen + +import androidx.compose.foundation.background +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.navigation.NavOptions +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.delay +import org.fnives.test.showcase.hilt.compose.screen.auth.AuthScreen +import org.fnives.test.showcase.hilt.compose.screen.auth.rememberAuthScreenState +import org.fnives.test.showcase.hilt.compose.screen.home.HomeScreen +import org.fnives.test.showcase.hilt.compose.screen.home.rememberHomeScreenState +import org.fnives.test.showcase.hilt.compose.screen.splash.SplashScreen +import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase + +@Composable +fun AppNavigation( + isUserLogeInUseCase: IsUserLoggedInUseCase = AppNavigationEntryPoint.get().isUserLoggedInUseCase +) { + val navController = rememberNavController() + + LaunchedEffect(isUserLogeInUseCase) { + val loginStateRoute = if (isUserLogeInUseCase.invoke()) RouteTag.HOME else RouteTag.AUTH + if (navController.currentDestination?.route == loginStateRoute) return@LaunchedEffect + delay(500) + navController.navigate( + route = loginStateRoute, + navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.SPLASH, inclusive = true).build() + ) + } + + NavHost( + navController, + startDestination = RouteTag.SPLASH, + modifier = Modifier.background(MaterialTheme.colors.surface) + ) { + composable(RouteTag.SPLASH) { SplashScreen() } + composable(RouteTag.AUTH) { + AuthScreen( + modifier = Modifier.testTag(AppNavigationTag.AuthScreen), + authScreenState = rememberAuthScreenState( + onLoginSuccess = { + navController.navigate( + route = RouteTag.HOME, + navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.AUTH, inclusive = true).build() + ) + } + ) + ) + } + composable(RouteTag.HOME) { + HomeScreen( + modifier = Modifier.testTag(AppNavigationTag.HomeScreen), + homeScreenState = rememberHomeScreenState( + onLogout = { + navController.navigate( + route = RouteTag.AUTH, + navOptions = NavOptions.Builder().setPopUpTo(route = RouteTag.HOME, inclusive = true).build() + ) + } + ) + ) + } + } +} + +object RouteTag { + const val HOME = "Home" + const val AUTH = "Auth" + const val SPLASH = "Splash" +} + +object AppNavigationTag { + const val AuthScreen = "AppNavigationTag.AuthScreen" + const val HomeScreen = "AppNavigationTag.HomeScreen" +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt new file mode 100644 index 0000000..d04c7a1 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/AppNavigationEntryPoint.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.compose.screen + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase + +object AppNavigationEntryPoint { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AppNavigationDependencies { + val isUserLoggedInUseCase: IsUserLoggedInUseCase + } + + @Composable + fun get(): AppNavigationDependencies { + val context = LocalContext.current.applicationContext + return remember { EntryPoints.get(context, AppNavigationDependencies::class.java) } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt new file mode 100644 index 0000000..3193ef8 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthEntryPoint.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.compose.screen.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.login.LoginUseCase + +object AuthEntryPoint { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AuthDependencies { + val loginUseCase: LoginUseCase + } + + @Composable + fun get(): AuthDependencies { + val context = LocalContext.current.applicationContext + return remember { EntryPoints.get(context, AuthDependencies::class.java) } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreen.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreen.kt new file mode 100644 index 0000000..8e8dab4 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreen.kt @@ -0,0 +1,211 @@ +package org.fnives.test.showcase.hilt.compose.screen.auth + +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Snackbar +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import com.google.accompanist.insets.statusBarsPadding +import org.fnives.test.showcase.hilt.R + +@Composable +fun AuthScreen( + modifier: Modifier = Modifier, + authScreenState: AuthScreenState = rememberAuthScreenState() +) { + ConstraintLayout(modifier.fillMaxSize()) { + val (title, credentials, snackbar, loading, login) = createRefs() + Title( + modifier = Modifier + .statusBarsPadding() + .constrainAs(title) { top.linkTo(parent.top) } + ) + CredentialsFields( + authScreenState = authScreenState, + modifier = Modifier.constrainAs(credentials) { + top.linkTo(title.bottom) + bottom.linkTo(login.top) + } + ) + Snackbar( + authScreenState = authScreenState, + modifier = Modifier.constrainAs(snackbar) { + bottom.linkTo(login.top) + } + ) + if (authScreenState.loading) { + CircularProgressIndicator( + Modifier + .testTag(AuthScreenTag.LoadingIndicator) + .constrainAs(loading) { + bottom.linkTo(login.top) + centerHorizontallyTo(parent) + } + ) + } + LoginButton( + modifier = Modifier + .constrainAs(login) { bottom.linkTo(parent.bottom) } + .padding(16.dp), + onClick = { authScreenState.onLogin() } + ) + } +} + +@Composable +private fun CredentialsFields(authScreenState: AuthScreenState, modifier: Modifier = Modifier) { + Column( + modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + UsernameField(authScreenState) + PasswordField(authScreenState) + } +} + +@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi::class) +@Composable +private fun PasswordField(authScreenState: AuthScreenState) { + var passwordVisible by remember { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + value = authScreenState.password, + label = { Text(text = stringResource(id = R.string.password)) }, + placeholder = { Text(text = stringResource(id = R.string.password)) }, + trailingIcon = { + val image = AnimatedImageVector.animatedVectorResource(R.drawable.show_password) + Icon( + painter = rememberAnimatedVectorPainter(image, passwordVisible), + contentDescription = null, + modifier = Modifier + .clickable { passwordVisible = !passwordVisible } + .testTag(AuthScreenTag.PasswordVisibilityToggle) + ) + }, + onValueChange = { authScreenState.onPasswordChanged(it) }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Password + ), + keyboardActions = KeyboardActions(onDone = { + keyboardController?.hide() + authScreenState.onLogin() + }), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .testTag(AuthScreenTag.PasswordInput) + ) +} + +@Composable +private fun UsernameField(authScreenState: AuthScreenState) { + OutlinedTextField( + value = authScreenState.username, + label = { Text(text = stringResource(id = R.string.username)) }, + placeholder = { Text(text = stringResource(id = R.string.username)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + onValueChange = { authScreenState.onUsernameChanged(it) }, + modifier = Modifier + .fillMaxWidth() + .testTag(AuthScreenTag.UsernameInput) + ) +} + +@Composable +private fun Snackbar(authScreenState: AuthScreenState, modifier: Modifier = Modifier) { + val snackbarState = remember { SnackbarHostState() } + val error = authScreenState.error + LaunchedEffect(error) { + if (error != null) { + snackbarState.showSnackbar(error.name) + authScreenState.dismissError() + } + } + SnackbarHost(hostState = snackbarState, modifier) { + val stringId = error?.stringResId() + if (stringId != null) { + Snackbar(modifier = Modifier.padding(horizontal = 16.dp)) { + Text(text = stringResource(stringId), Modifier.testTag(AuthScreenTag.LoginError)) + } + } + } +} + +@Composable +private fun LoginButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + Box(modifier) { + Button( + onClick = onClick, + Modifier + .fillMaxWidth() + .testTag(AuthScreenTag.LoginButton) + ) { + Text(text = "Login") + } + } +} + +@Composable +private fun Title(modifier: Modifier = Modifier) { + Text( + stringResource(id = R.string.login_title), + modifier = modifier.padding(16.dp), + style = MaterialTheme.typography.h4 + ) +} + +private fun AuthScreenState.ErrorType.stringResId() = when (this) { + AuthScreenState.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid + AuthScreenState.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong + AuthScreenState.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid + AuthScreenState.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid +} + +object AuthScreenTag { + const val UsernameInput = "AuthScreenTag.UsernameInput" + const val PasswordInput = "AuthScreenTag.PasswordInput" + const val LoadingIndicator = "AuthScreenTag.LoadingIndicator" + const val LoginButton = "AuthScreenTag.LoginButton" + const val LoginError = "AuthScreenTag.LoginError" + const val PasswordVisibilityToggle = "AuthScreenTag.PasswordVisibilityToggle" +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt new file mode 100644 index 0000000..49454f6 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/auth/AuthScreenState.kt @@ -0,0 +1,109 @@ +package org.fnives.test.showcase.hilt.compose.screen.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.login.LoginUseCase +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer + +@Composable +fun rememberAuthScreenState( + stateScope: CoroutineScope = rememberCoroutineScope { Dispatchers.Main }, + loginUseCase: LoginUseCase = AuthEntryPoint.get().loginUseCase, + onLoginSuccess: () -> Unit = {}, +): AuthScreenState { + return rememberSaveable(saver = AuthScreenState.getSaver(stateScope, loginUseCase, onLoginSuccess)) { + AuthScreenState(stateScope, loginUseCase, onLoginSuccess) + } +} + +class AuthScreenState( + private val stateScope: CoroutineScope, + private val loginUseCase: LoginUseCase, + private val onLoginSuccess: () -> Unit = {}, +) { + + var username by mutableStateOf("") + private set + var password by mutableStateOf("") + private set + var loading by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + + fun onUsernameChanged(username: String) { + this.username = username + } + + fun onPasswordChanged(password: String) { + this.password = password + } + + fun onLogin() { + if (loading) { + return + } + loading = true + stateScope.launch { + val credentials = LoginCredentials( + username = username, + password = password + ) + when (val response = loginUseCase.invoke(credentials)) { + is Answer.Error -> error = ErrorType.GENERAL_NETWORK_ERROR + is Answer.Success -> processLoginStatus(response.data) + } + loading = false + } + } + + private fun processLoginStatus(loginStatus: LoginStatus) { + when (loginStatus) { + LoginStatus.SUCCESS -> onLoginSuccess() + LoginStatus.INVALID_CREDENTIALS -> error = ErrorType.INVALID_CREDENTIALS + LoginStatus.INVALID_USERNAME -> error = ErrorType.UNSUPPORTED_USERNAME + LoginStatus.INVALID_PASSWORD -> error = ErrorType.UNSUPPORTED_PASSWORD + } + } + + fun dismissError() { + error = null + } + + enum class ErrorType { + INVALID_CREDENTIALS, + GENERAL_NETWORK_ERROR, + UNSUPPORTED_USERNAME, + UNSUPPORTED_PASSWORD + } + + companion object { + private const val USERNAME = "USERNAME" + private const val PASSWORD = "PASSWORD" + + fun getSaver( + stateScope: CoroutineScope, + loginUseCase: LoginUseCase, + onLoginSuccess: () -> Unit, + ): Saver = mapSaver( + save = { mapOf(USERNAME to it.username, PASSWORD to it.password) }, + restore = { + AuthScreenState(stateScope, loginUseCase, onLoginSuccess).apply { + onUsernameChanged(it.getOrElse(USERNAME) { "" } as String) + onPasswordChanged(it.getOrElse(PASSWORD) { "" } as String) + } + } + ) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt new file mode 100644 index 0000000..c608e36 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeEntryPoint.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.hilt.compose.screen.home + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase +import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase +import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase + +object HomeEntryPoint { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface MainDependencies { + val getAllContentUseCase: GetAllContentUseCase + val logoutUseCase: LogoutUseCase + val fetchContentUseCase: FetchContentUseCase + val addContentToFavouriteUseCase: AddContentToFavouriteUseCase + val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase + } + + @Composable + fun get(): MainDependencies { + val context = LocalContext.current.applicationContext + return remember { EntryPoints.get(context, MainDependencies::class.java) } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreen.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreen.kt new file mode 100644 index 0000000..6e61b19 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreen.kt @@ -0,0 +1,125 @@ +package org.fnives.test.showcase.hilt.compose.screen.home + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil.compose.rememberImagePainter +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.model.content.FavouriteContent + +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, + homeScreenState: HomeScreenState = rememberHomeScreenState() +) { + Column(modifier.fillMaxSize()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Title(Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.logout_24), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colors.primary), + modifier = Modifier + .padding(16.dp) + .clickable { homeScreenState.onLogout() } + ) + } + Box { + if (homeScreenState.isError) { + ErrorText(Modifier.align(Alignment.Center)) + } + SwipeRefresh( + state = rememberSwipeRefreshState(isRefreshing = homeScreenState.loading), + onRefresh = { + homeScreenState.onRefresh() + } + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(homeScreenState.content) { item -> + Item( + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + favouriteContent = item, + onFavouriteToggle = { homeScreenState.onFavouriteToggleClicked(item.content.id) } + ) + } + } + } + } + } +} + +@Composable +private fun Item( + modifier: Modifier = Modifier, + favouriteContent: FavouriteContent, + onFavouriteToggle: () -> Unit, +) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Image( + painter = rememberImagePainter(favouriteContent.content.imageUrl.url), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .height(120.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + ) + Column( + Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + Text(text = favouriteContent.content.title) + Text(text = favouriteContent.content.description) + } + val favouriteIcon = if (favouriteContent.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24 + Image( + painter = painterResource(id = favouriteIcon), + contentDescription = null, + Modifier.clickable { onFavouriteToggle() } + ) + } +} + +@Composable +private fun Title(modifier: Modifier = Modifier) { + Text( + stringResource(id = R.string.login_title), + modifier = modifier.padding(16.dp), + style = MaterialTheme.typography.h4 + ) +} + +@Composable +private fun ErrorText(modifier: Modifier = Modifier) { + Text( + stringResource(id = R.string.something_went_wrong), + modifier = modifier.padding(16.dp), + style = MaterialTheme.typography.h4, + textAlign = TextAlign.Center + ) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt new file mode 100644 index 0000000..a7e2643 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/home/HomeScreenState.kt @@ -0,0 +1,133 @@ +package org.fnives.test.showcase.hilt.compose.screen.home + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase +import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase +import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.shared.Resource + +@Composable +fun rememberHomeScreenState( + stateScope: CoroutineScope = rememberCoroutineScope(), + mainDependencies: HomeEntryPoint.MainDependencies = HomeEntryPoint.get(), + onLogout: () -> Unit = {}, +) = + rememberHomeScreenState( + stateScope = stateScope, + getAllContentUseCase = mainDependencies.getAllContentUseCase, + logoutUseCase = mainDependencies.logoutUseCase, + fetchContentUseCase = mainDependencies.fetchContentUseCase, + addContentToFavouriteUseCase = mainDependencies.addContentToFavouriteUseCase, + removeContentFromFavouritesUseCase = mainDependencies.removeContentFromFavouritesUseCase, + onLogout = onLogout, + ) + +@Suppress("LongParameterList") +@Composable +fun rememberHomeScreenState( + stateScope: CoroutineScope = rememberCoroutineScope(), + getAllContentUseCase: GetAllContentUseCase, + logoutUseCase: LogoutUseCase, + fetchContentUseCase: FetchContentUseCase, + addContentToFavouriteUseCase: AddContentToFavouriteUseCase, + removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase, + onLogout: () -> Unit = {}, +): HomeScreenState { + return remember { + HomeScreenState( + stateScope, + getAllContentUseCase, + logoutUseCase, + fetchContentUseCase, + addContentToFavouriteUseCase, + removeContentFromFavouritesUseCase, + onLogout, + ) + } +} + +@Suppress("LongParameterList") +class HomeScreenState( + private val stateScope: CoroutineScope, + private val getAllContentUseCase: GetAllContentUseCase, + private val logoutUseCase: LogoutUseCase, + private val fetchContentUseCase: FetchContentUseCase, + private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase, + private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase, + private val logoutEvent: () -> Unit, +) { + + var loading by mutableStateOf(false) + private set + var isError by mutableStateOf(false) + private set + var content by mutableStateOf>(emptyList()) + private set + + init { + stateScope.launch { + fetch().collect { + content = it + } + } + } + + private fun fetch() = getAllContentUseCase.get() + .mapNotNull { + when (it) { + is Resource.Error -> { + isError = true + loading = false + return@mapNotNull emptyList() + } + is Resource.Loading -> { + isError = false + loading = true + return@mapNotNull null + } + is Resource.Success -> { + isError = false + loading = false + return@mapNotNull it.data + } + } + } + + fun onLogout() { + stateScope.launch { + logoutUseCase.invoke() + logoutEvent() + } + } + + fun onRefresh() { + if (loading) return + loading = true + stateScope.launch { + fetchContentUseCase.invoke() + } + } + + fun onFavouriteToggleClicked(contentId: ContentId) { + stateScope.launch { + val item = content.firstOrNull { it.content.id == contentId } ?: return@launch + if (item.isFavourite) { + removeContentFromFavouritesUseCase.invoke(contentId) + } else { + addContentToFavouriteUseCase.invoke(contentId) + } + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/splash/SplashScreen.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/splash/SplashScreen.kt new file mode 100644 index 0000000..19029d8 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/compose/screen/splash/SplashScreen.kt @@ -0,0 +1,37 @@ +package org.fnives.test.showcase.hilt.compose.screen.splash + +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.fnives.test.showcase.hilt.R + +@Composable +fun SplashScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .background(colorResource(R.color.purple_700)), + contentAlignment = Alignment.Center + ) { + val resourceId = if (VERSION.SDK_INT >= VERSION_CODES.N) { + R.drawable.ic_launcher_foreground + } else { + R.mipmap.ic_launcher_round + } + Image( + painter = painterResource(resourceId), + contentDescription = null, + modifier = Modifier.size(120.dp) + ) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/AppModule.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/AppModule.kt new file mode 100644 index 0000000..31edc36 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/AppModule.kt @@ -0,0 +1,42 @@ +package org.fnives.test.showcase.hilt.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.hilt.session.SessionExpirationListenerImpl +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl +import org.fnives.test.showcase.hilt.storage.favourite.FavouriteContentLocalStorageImpl +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object AppModule { + + @Provides + fun enableLogging(): Boolean = true + + @Singleton + @Provides + fun provideFavouriteDao(localDatabase: LocalDatabase) = + localDatabase.favouriteDao + + @Provides + fun provideSharedPreferencesManagerImpl(@ApplicationContext context: Context) = + SharedPreferencesManagerImpl.create(context) + + @Provides + fun provideFavouriteContentLocalStorage( + favouriteContentLocalStorageImpl: FavouriteContentLocalStorageImpl + ): FavouriteContentLocalStorage = favouriteContentLocalStorageImpl + + @Provides + internal fun bindSessionExpirationListener( + sessionExpirationListenerImpl: SessionExpirationListenerImpl + ): SessionExpirationListener = sessionExpirationListenerImpl +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/BaseUrlModule.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/BaseUrlModule.kt new file mode 100644 index 0000000..8bb6c42 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/BaseUrlModule.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.BuildConfig + +@InstallIn(SingletonComponent::class) +@Module + +object BaseUrlModule { + + @Provides + fun provideBaseUrl(): String = BuildConfig.BASE_URL +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/StorageModule.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/StorageModule.kt new file mode 100644 index 0000000..b0028a2 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/StorageModule.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.hilt.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.storage.database.DatabaseInitialization +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object StorageModule { + @Singleton + @Provides + fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase = + DatabaseInitialization.create(context) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/UserDataLocalStorageModule.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/UserDataLocalStorageModule.kt new file mode 100644 index 0000000..07dcd4c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/di/UserDataLocalStorageModule.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.storage.SharedPreferencesManagerImpl +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object UserDataLocalStorageModule { + + @Singleton + @Provides + fun provideUserDataLocalStorage( + sharedPreferencesManagerImpl: SharedPreferencesManagerImpl + ): UserDataLocalStorage = sharedPreferencesManagerImpl +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/session/SessionExpirationListenerImpl.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/session/SessionExpirationListenerImpl.kt new file mode 100644 index 0000000..e53e33c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/session/SessionExpirationListenerImpl.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.session + +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import dagger.hilt.android.qualifiers.ApplicationContext +import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener +import org.fnives.test.showcase.hilt.ui.IntentCoordinator +import javax.inject.Inject + +class SessionExpirationListenerImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : SessionExpirationListener { + + override fun onSessionExpired() { + Handler(Looper.getMainLooper()).post { + context.startActivity( + IntentCoordinator.authActivitygetStartIntent(context) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/LocalDatabase.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/LocalDatabase.kt new file mode 100644 index 0000000..15f906c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/LocalDatabase.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.storage + +import androidx.room.Database +import androidx.room.RoomDatabase +import org.fnives.test.showcase.hilt.storage.favourite.FavouriteDao +import org.fnives.test.showcase.hilt.storage.favourite.FavouriteEntity + +@Database( + entities = [FavouriteEntity::class], + version = 2, + exportSchema = true +) +abstract class LocalDatabase : RoomDatabase() { + + abstract val favouriteDao: FavouriteDao +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/SharedPreferencesManagerImpl.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/SharedPreferencesManagerImpl.kt new file mode 100644 index 0000000..9172135 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/SharedPreferencesManagerImpl.kt @@ -0,0 +1,68 @@ +package org.fnives.test.showcase.hilt.storage + +import android.content.Context +import android.content.SharedPreferences +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class SharedPreferencesManagerImpl( + private val sharedPreferences: SharedPreferences +) : UserDataLocalStorage { + + override var session: Session? by SessionDelegate(SESSION_KEY) + + private class SessionDelegate(private val key: String) : + ReadWriteProperty { + + override fun setValue( + thisRef: SharedPreferencesManagerImpl, + property: KProperty<*>, + value: Session? + ) { + if (value == null) { + thisRef.sharedPreferences.edit().remove(key).apply() + } else { + val values = setOf( + ACCESS_TOKEN_KEY + value.accessToken, + REFRESH_TOKEN_KEY + value.refreshToken + ) + thisRef.sharedPreferences.edit().putStringSet(key, values).apply() + } + } + + override fun getValue( + thisRef: SharedPreferencesManagerImpl, + property: KProperty<*> + ): Session? { + val values = thisRef.sharedPreferences.getStringSet(key, null)?.toList() + val accessToken = values?.firstOrNull { it.startsWith(ACCESS_TOKEN_KEY) } + ?.drop(ACCESS_TOKEN_KEY.length) ?: return null + val refreshToken = values.firstOrNull { it.startsWith(REFRESH_TOKEN_KEY) } + ?.drop(REFRESH_TOKEN_KEY.length) ?: return null + + return Session(accessToken = accessToken, refreshToken = refreshToken) + } + + companion object { + private const val ACCESS_TOKEN_KEY = "ACCESS_TOKEN_KEY" + private const val REFRESH_TOKEN_KEY = "REFRESH_TOKEN_KEY" + } + } + + companion object { + + private const val SESSION_KEY = "SESSION_KEY" + private const val SESSION_SHARED_PREFERENCES_NAME = "SESSION_SHARED_PREFERENCES_NAME" + + fun create(context: Context): SharedPreferencesManagerImpl { + val sharedPreferences = context.getSharedPreferences( + SESSION_SHARED_PREFERENCES_NAME, + Context.MODE_PRIVATE + ) + + return SharedPreferencesManagerImpl(sharedPreferences) + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/database/DatabaseInitialization.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/database/DatabaseInitialization.kt new file mode 100644 index 0000000..dd147d9 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/database/DatabaseInitialization.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.hilt.storage.database + +import android.content.Context +import androidx.room.Room +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.storage.migation.Migration1To2 + +object DatabaseInitialization { + + fun create(context: Context): LocalDatabase = + Room.databaseBuilder(context, LocalDatabase::class.java, "local_database") + .addMigrations(Migration1To2()) + .allowMainThreadQueries() + .build() +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImpl.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImpl.kt new file mode 100644 index 0000000..78b3479 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImpl.kt @@ -0,0 +1,23 @@ +package org.fnives.test.showcase.hilt.storage.favourite + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import javax.inject.Inject + +class FavouriteContentLocalStorageImpl @Inject constructor( + private val favouriteDao: FavouriteDao +) : FavouriteContentLocalStorage { + + override fun observeFavourites(): Flow> = + favouriteDao.get().map { it.map(FavouriteEntity::contentId).map(::ContentId) } + + override suspend fun markAsFavourite(contentId: ContentId) { + favouriteDao.addFavourite(FavouriteEntity(contentId.id)) + } + + override suspend fun deleteAsFavourite(contentId: ContentId) { + favouriteDao.deleteFavourite(FavouriteEntity(contentId.id)) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteDao.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteDao.kt new file mode 100644 index 0000000..00d4a9e --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteDao.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.hilt.storage.favourite + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface FavouriteDao { + + @Query("SELECT * FROM FavouriteEntity") + fun get(): Flow> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun addFavourite(favouriteEntity: FavouriteEntity) + + @Delete + suspend fun deleteFavourite(favouriteEntity: FavouriteEntity) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteEntity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteEntity.kt new file mode 100644 index 0000000..e957540 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteEntity.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.storage.favourite + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class FavouriteEntity( + @ColumnInfo(name = "content_id") + @PrimaryKey val contentId: String +) diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/migation/Migration1To2.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/migation/Migration1To2.kt new file mode 100644 index 0000000..d459d87 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/storage/migation/Migration1To2.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.hilt.storage.migation + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration1To2 : Migration(1, 2) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE FavouriteEntity RENAME TO FavouriteEntityOld") + database.execSQL("CREATE TABLE FavouriteEntity(content_id TEXT NOT NULL PRIMARY KEY)") + database.execSQL("INSERT INTO FavouriteEntity(content_id) SELECT contentId FROM FavouriteEntityOld") + database.execSQL("DROP TABLE FavouriteEntityOld") + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/IntentCoordinator.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/IntentCoordinator.kt new file mode 100644 index 0000000..4960264 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/IntentCoordinator.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.hilt.ui + +import android.content.Context +import android.content.Intent +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.hilt.ui.home.MainActivity + +object IntentCoordinator { + + fun mainActivitygetStartIntent(context: Context): Intent = + MainActivity.getStartIntent(context) + + fun authActivitygetStartIntent(context: Context): Intent = + AuthActivity.getStartIntent(context) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/ViewModelDelegate.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/ViewModelDelegate.kt new file mode 100644 index 0000000..f6ec4fd --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/ViewModelDelegate.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.ui + +import androidx.activity.ComponentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.activity.viewModels as androidxViewModel + +inline fun ViewModelStoreOwner.viewModels(): Lazy = + when (this) { + is ComponentActivity -> androidxViewModel() + else -> throw IllegalStateException("Only supports activity viewModel for now") + } diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivity.kt new file mode 100644 index 0000000..998bc1c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivity.kt @@ -0,0 +1,57 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.databinding.ActivityAuthenticationBinding +import org.fnives.test.showcase.hilt.ui.IntentCoordinator +import org.fnives.test.showcase.hilt.ui.viewModels + +@AndroidEntryPoint +class AuthActivity : AppCompatActivity() { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityAuthenticationBinding.inflate(layoutInflater) + viewModel.loading.observe(this) { + binding.loadingIndicator.isVisible = it == true + } + viewModel.password.observe(this, SetTextIfNotSameObserver(binding.passwordEditText)) + binding.passwordEditText.doAfterTextChanged { viewModel.onPasswordChanged(it?.toString().orEmpty()) } + viewModel.username.observe(this, SetTextIfNotSameObserver(binding.userEditText)) + binding.userEditText.doAfterTextChanged { viewModel.onUsernameChanged(it?.toString().orEmpty()) } + binding.loginCta.setOnClickListener { + viewModel.onLogin() + } + viewModel.error.observe(this) { + val stringResId = it?.consume()?.stringResId() ?: return@observe + Snackbar.make(binding.snackbarHolder, stringResId, Snackbar.LENGTH_LONG).show() + } + viewModel.navigateToHome.observe(this) { + it.consume() ?: return@observe + startActivity(IntentCoordinator.mainActivitygetStartIntent(this)) + finishAffinity() + } + setContentView(binding.root) + } + + companion object { + + private fun AuthViewModel.ErrorType.stringResId() = when (this) { + AuthViewModel.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid + AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong + AuthViewModel.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid + AuthViewModel.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid + } + + fun getStartIntent(context: Context): Intent = Intent(context, AuthActivity::class.java) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModel.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..fdf1e66 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModel.kt @@ -0,0 +1,69 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.login.LoginUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor(private val loginUseCase: LoginUseCase) : ViewModel() { + + private val _username = MutableLiveData() + val username: LiveData = _username + private val _password = MutableLiveData() + val password: LiveData = _password + private val _loading = MutableLiveData(false) + val loading: LiveData = _loading + private val _error = MutableLiveData>() + val error: LiveData> = _error + private val _navigateToHome = MutableLiveData>() + val navigateToHome: LiveData> = _navigateToHome + + fun onPasswordChanged(password: String) { + _password.value = password + } + + fun onUsernameChanged(username: String) { + _username.value = username + } + + fun onLogin() { + if (_loading.value == true) return + _loading.value = true + viewModelScope.launch { + val credentials = LoginCredentials( + username = _username.value.orEmpty(), + password = _password.value.orEmpty() + ) + when (val response = loginUseCase.invoke(credentials)) { + is Answer.Error -> _error.value = Event(ErrorType.GENERAL_NETWORK_ERROR) + is Answer.Success -> processLoginStatus(response.data) + } + _loading.postValue(false) + } + } + + private fun processLoginStatus(loginStatus: LoginStatus) { + when (loginStatus) { + LoginStatus.SUCCESS -> _navigateToHome.value = Event(Unit) + LoginStatus.INVALID_CREDENTIALS -> _error.value = Event(ErrorType.INVALID_CREDENTIALS) + LoginStatus.INVALID_USERNAME -> _error.value = Event(ErrorType.UNSUPPORTED_USERNAME) + LoginStatus.INVALID_PASSWORD -> _error.value = Event(ErrorType.UNSUPPORTED_PASSWORD) + } + } + + enum class ErrorType { + INVALID_CREDENTIALS, + GENERAL_NETWORK_ERROR, + UNSUPPORTED_USERNAME, + UNSUPPORTED_PASSWORD + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/SetTextIfNotSameObserver.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/SetTextIfNotSameObserver.kt new file mode 100644 index 0000000..1c450f6 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/auth/SetTextIfNotSameObserver.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import android.widget.EditText +import androidx.lifecycle.Observer + +class SetTextIfNotSameObserver(private val editText: EditText) : Observer { + override fun onChanged(t: String?) { + val current = editText.text?.toString() + if (current != t) { + editText.setText(t) + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/FavouriteContentAdapter.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/FavouriteContentAdapter.kt new file mode 100644 index 0000000..939feac --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/FavouriteContentAdapter.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.hilt.ui.home + +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.databinding.ItemFavouriteContentBinding +import org.fnives.test.showcase.hilt.ui.shared.ViewBindingAdapter +import org.fnives.test.showcase.hilt.ui.shared.executor.AsyncTaskExecutor +import org.fnives.test.showcase.hilt.ui.shared.layoutInflater +import org.fnives.test.showcase.hilt.ui.shared.loadRoundedImage +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent + +class FavouriteContentAdapter( + private val listener: OnFavouriteItemClicked, +) : ListAdapter>( + AsyncDifferConfig.Builder(DiffUtilItemCallback()) + .setBackgroundThreadExecutor(AsyncTaskExecutor.iOThreadExecutor) + .build() +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewBindingAdapter = + ViewBindingAdapter(ItemFavouriteContentBinding.inflate(parent.layoutInflater(), parent, false)).apply { + viewBinding.favouriteCta.setOnClickListener { + if (adapterPosition in 0 until itemCount) { + listener.onFavouriteToggleClicked(getItem(adapterPosition).content.id) + } + } + } + + override fun onBindViewHolder(holder: ViewBindingAdapter, position: Int) { + val item = getItem(position) + holder.viewBinding.img.loadRoundedImage(item.content.imageUrl) + holder.viewBinding.title.text = item.content.title + holder.viewBinding.description.text = item.content.description + val favouriteResId = if (item.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24 + holder.viewBinding.favouriteCta.setImageResource(favouriteResId) + } + + interface OnFavouriteItemClicked { + fun onFavouriteToggleClicked(contentId: ContentId) + } + + class DiffUtilItemCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean = + oldItem.content.id == newItem.content.id + + override fun areContentsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean = + oldItem == newItem + + override fun getChangePayload(oldItem: FavouriteContent, newItem: FavouriteContent): Any? = oldItem + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainActivity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainActivity.kt new file mode 100644 index 0000000..0e442b4 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainActivity.kt @@ -0,0 +1,72 @@ +package org.fnives.test.showcase.hilt.ui.home + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.databinding.ActivityMainBinding +import org.fnives.test.showcase.hilt.ui.IntentCoordinator +import org.fnives.test.showcase.hilt.ui.shared.VerticalSpaceItemDecoration +import org.fnives.test.showcase.hilt.ui.shared.getThemePrimaryColor +import org.fnives.test.showcase.hilt.ui.viewModels +import org.fnives.test.showcase.model.content.ContentId + +@AndroidEntryPoint +open class MainActivity : AppCompatActivity() { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityMainBinding.inflate(layoutInflater) + binding.toolbar.menu?.findItem(R.id.logout_cta)?.setOnMenuItemClickListener { + viewModel.onLogout() + true + } + binding.swipeRefreshLayout.setColorSchemeColors(binding.swipeRefreshLayout.getThemePrimaryColor()) + binding.swipeRefreshLayout.setOnRefreshListener { + viewModel.onRefresh() + } + + val adapter = FavouriteContentAdapter(viewModel.mapToAdapterListener()) + binding.recycler.layoutManager = LinearLayoutManager(this) + binding.recycler.addItemDecoration( + VerticalSpaceItemDecoration(resources.getDimensionPixelOffset(R.dimen.padding)) + ) + binding.recycler.adapter = adapter + + viewModel.content.observe(this) { + adapter.submitList(it.orEmpty()) + } + viewModel.errorMessage.observe(this) { + binding.errorMessage.isVisible = it == true + } + viewModel.navigateToAuth.observe(this) { + it.consume() ?: return@observe + startActivity(IntentCoordinator.authActivitygetStartIntent(this)) + finishAffinity() + } + viewModel.loading.observe(this) { + if (binding.swipeRefreshLayout.isRefreshing != it) { + binding.swipeRefreshLayout.isRefreshing = it == true + } + } + + setContentView(binding.root) + } + + companion object { + fun getStartIntent(context: Context): Intent = Intent(context, MainActivity::class.java) + + private fun MainViewModel.mapToAdapterListener(): FavouriteContentAdapter.OnFavouriteItemClicked = + object : FavouriteContentAdapter.OnFavouriteItemClicked { + override fun onFavouriteToggleClicked(contentId: ContentId) { + this@mapToAdapterListener.onFavouriteToggleClicked(contentId) + } + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainViewModel.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainViewModel.kt new file mode 100644 index 0000000..030679c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/home/MainViewModel.kt @@ -0,0 +1,84 @@ +package org.fnives.test.showcase.hilt.ui.home + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase +import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase +import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.shared.Resource +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val getAllContentUseCase: GetAllContentUseCase, + private val logoutUseCase: LogoutUseCase, + private val fetchContentUseCase: FetchContentUseCase, + private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase, + private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase +) : ViewModel() { + + private val _loading = MutableLiveData() + val loading: LiveData = _loading + private val _content: LiveData> = liveData { + getAllContentUseCase.get().collect { + when (it) { + is Resource.Error -> { + _errorMessage.value = true + _loading.value = false + emit(emptyList()) + } + is Resource.Loading -> { + _errorMessage.value = false + _loading.value = true + } + is Resource.Success -> { + _errorMessage.value = false + _loading.value = false + emit(it.data) + } + } + } + } + val content: LiveData> = _content + private val _errorMessage = MutableLiveData(false) + val errorMessage: LiveData = _errorMessage.distinctUntilChanged() + private val _navigateToAuth = MutableLiveData>() + val navigateToAuth: LiveData> = _navigateToAuth + + fun onLogout() { + viewModelScope.launch { + logoutUseCase.invoke() + _navigateToAuth.value = Event(Unit) + } + } + + fun onRefresh() { + if (_loading.value == true) return + _loading.value = true + viewModelScope.launch { + fetchContentUseCase.invoke() + } + } + + fun onFavouriteToggleClicked(contentId: ContentId) { + viewModelScope.launch { + val content = _content.value?.firstOrNull { it.content.id == contentId } ?: return@launch + if (content.isFavourite) { + removeContentFromFavouritesUseCase.invoke(contentId) + } else { + addContentToFavouriteUseCase.invoke(contentId) + } + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/Event.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/Event.kt new file mode 100644 index 0000000..7cce3a9 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/Event.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.ui.shared + +@Suppress("DataClassContainsFunctions") +data class Event(private val data: T) { + + private var consumed: Boolean = false + + fun consume(): T? = data.takeUnless { consumed }?.also { consumed = true } + + fun peek() = data +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/VerticalSpaceItemDecoration.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/VerticalSpaceItemDecoration.kt new file mode 100644 index 0000000..b8fadc7 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/VerticalSpaceItemDecoration.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.ui.shared + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int) : ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + outRect.set(0, 0, 0, verticalSpaceHeight) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewBindingAdapter.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewBindingAdapter.kt new file mode 100644 index 0000000..cd1c1a5 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewBindingAdapter.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.ui.shared + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +class ViewBindingAdapter(val viewBinding: T) : RecyclerView.ViewHolder(viewBinding.root) diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewExtension.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewExtension.kt new file mode 100644 index 0000000..26fe1ec --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/ViewExtension.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.hilt.ui.shared + +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import coil.load +import coil.transform.RoundedCornersTransformation +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.model.content.ImageUrl + +fun View.layoutInflater(): LayoutInflater = LayoutInflater.from(context) + +fun ImageView.loadRoundedImage(imageUrl: ImageUrl) { + load(imageUrl.url) { + transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.rounded_corner))) + } +} + +fun View.getThemePrimaryColor(): Int { + val value = TypedValue() + context.theme.resolveAttribute(R.attr.colorPrimary, value, true) + return value.data +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/AsyncTaskExecutor.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/AsyncTaskExecutor.kt new file mode 100644 index 0000000..c6c4773 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/AsyncTaskExecutor.kt @@ -0,0 +1,28 @@ +package org.fnives.test.showcase.hilt.ui.shared.executor + +import java.util.concurrent.Executor + +/** + * Basic copy of [ArchTaskExecutor][androidx.arch.core.executor.ArchTaskExecutor], needed because that is restricted to Library. + * + * Intended to be used for [AsyncDifferConfig][androidx.recyclerview.widget.AsyncDifferConfig] so it can be synchronized with Espresso. + * + * Workaround until https://github.com/android/android-test/issues/382 is fixed finally. + */ +object AsyncTaskExecutor : TaskExecutor { + + val mainThreadExecutor = Executor { command -> postToMainThread(command) } + val iOThreadExecutor = Executor { command -> executeOnDiskIO(command) } + + var delegate: TaskExecutor? = null + private val defaultExecutor by lazy { DefaultTaskExecutor() } + private val executor get() = delegate ?: defaultExecutor + + override fun executeOnDiskIO(runnable: Runnable) { + executor.executeOnDiskIO(runnable) + } + + override fun postToMainThread(runnable: Runnable) { + executor.postToMainThread(runnable) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/DefaultTaskExecutor.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/DefaultTaskExecutor.kt new file mode 100644 index 0000000..9f2fb92 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/DefaultTaskExecutor.kt @@ -0,0 +1,34 @@ +package org.fnives.test.showcase.hilt.ui.shared.executor + +import android.os.Build +import android.os.Handler +import android.os.Looper +import java.util.concurrent.Executors + +/** + * Basic copy of [androidx.arch.core.executor.DefaultTaskExecutor], needed because that is restricted to Library. + * With a Flavour of [androidx.recyclerview.widget.AsyncDifferConfig]. + * Used within [AsyncTaskExecutor]. + * + * Intended to be used for AsyncDiffUtil so it can be synchronized with Espresso. + */ +class DefaultTaskExecutor : TaskExecutor { + + private val diskIO = Executors.newFixedThreadPool(2) + private val mMainHandler: Handler by lazy { createAsync(Looper.getMainLooper()) } + + override fun executeOnDiskIO(runnable: Runnable) { + diskIO.execute(runnable) + } + + override fun postToMainThread(runnable: Runnable) { + mMainHandler.post(runnable) + } + + private fun createAsync(looper: Looper): Handler = + if (Build.VERSION.SDK_INT >= 28) { + Handler.createAsync(looper) + } else { + Handler(looper) + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/TaskExecutor.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/TaskExecutor.kt new file mode 100644 index 0000000..cfd3d1d --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/shared/executor/TaskExecutor.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.shared.executor + +/** + * Define TaskExecutor intended for [AsyncDifferConfig][androidx.recyclerview.widget.AsyncDifferConfig] + */ +interface TaskExecutor { + fun executeOnDiskIO(runnable: Runnable) + + fun postToMainThread(runnable: Runnable) +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivity.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivity.kt new file mode 100644 index 0000000..fb60bf6 --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivity.kt @@ -0,0 +1,30 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.ui.IntentCoordinator +import org.fnives.test.showcase.hilt.ui.viewModels + +@SuppressLint("CustomSplashScreen") +@AndroidEntryPoint +open class SplashActivity : AppCompatActivity() { + + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_splash) + viewModel.navigateTo.observe(this) { + val intent = when (it.consume()) { + SplashViewModel.NavigateTo.HOME -> IntentCoordinator.mainActivitygetStartIntent(this) + SplashViewModel.NavigateTo.AUTHENTICATION -> IntentCoordinator.authActivitygetStartIntent(this) + null -> return@observe + } + startActivity(intent) + finishAffinity() + } + } +} diff --git a/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModel.kt b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModel.kt new file mode 100644 index 0000000..da6ad8c --- /dev/null +++ b/hilt/hilt-app/src/main/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModel.kt @@ -0,0 +1,31 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor(isUserLoggedInUseCase: IsUserLoggedInUseCase) : ViewModel() { + + private val _navigateTo = MutableLiveData>() + val navigateTo: LiveData> = _navigateTo + + init { + viewModelScope.launch { + delay(500L) + val navigationEvent = if (isUserLoggedInUseCase.invoke()) NavigateTo.HOME else NavigateTo.AUTHENTICATION + _navigateTo.value = Event(navigationEvent) + } + } + + enum class NavigateTo { + HOME, AUTHENTICATION + } +} diff --git a/hilt/hilt-app/src/main/res/drawable-v24/ic_compose_launcher_foreground.xml b/hilt/hilt-app/src/main/res/drawable-v24/ic_compose_launcher_foreground.xml new file mode 100644 index 0000000..327652c --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable-v24/ic_compose_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/hilt/hilt-app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..da1dcd9 --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/drawable/favorite_24.xml b/hilt/hilt-app/src/main/res/drawable/favorite_24.xml new file mode 100644 index 0000000..209e42e --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/favorite_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/hilt/hilt-app/src/main/res/drawable/favorite_border_24.xml b/hilt/hilt-app/src/main/res/drawable/favorite_border_24.xml new file mode 100644 index 0000000..83e57ce --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/favorite_border_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/hilt/hilt-app/src/main/res/drawable/ic_launcher_background.xml b/hilt/hilt-app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b219d51 --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/hilt/hilt-app/src/main/res/drawable/logout_24.xml b/hilt/hilt-app/src/main/res/drawable/logout_24.xml new file mode 100644 index 0000000..77928df --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/logout_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/hilt/hilt-app/src/main/res/drawable/show_password.xml b/hilt/hilt-app/src/main/res/drawable/show_password.xml new file mode 100644 index 0000000..6d35533 --- /dev/null +++ b/hilt/hilt-app/src/main/res/drawable/show_password.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hilt/hilt-app/src/main/res/layout/activity_authentication.xml b/hilt/hilt-app/src/main/res/layout/activity_authentication.xml new file mode 100644 index 0000000..f012327 --- /dev/null +++ b/hilt/hilt-app/src/main/res/layout/activity_authentication.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/layout/activity_main.xml b/hilt/hilt-app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..908365c --- /dev/null +++ b/hilt/hilt-app/src/main/res/layout/activity_main.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/layout/activity_splash.xml b/hilt/hilt-app/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..c758e5a --- /dev/null +++ b/hilt/hilt-app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/layout/item_favourite_content.xml b/hilt/hilt-app/src/main/res/layout/item_favourite_content.xml new file mode 100644 index 0000000..c4115ca --- /dev/null +++ b/hilt/hilt-app/src/main/res/layout/item_favourite_content.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/menu/main.xml b/hilt/hilt-app/src/main/res/menu/main.xml new file mode 100644 index 0000000..f42dec2 --- /dev/null +++ b/hilt/hilt-app/src/main/res/menu/main.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher.xml b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher.xml new file mode 100644 index 0000000..bf2bcc9 --- /dev/null +++ b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher_round.xml b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher_round.xml new file mode 100644 index 0000000..bf2bcc9 --- /dev/null +++ b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_compose_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/hilt/hilt-app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher.png b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher.png new file mode 100644 index 0000000..d2ba5af Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher_round.png new file mode 100644 index 0000000..28e1d9e Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_compose_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher.png b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..1d4a022 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..d883c08 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher.png b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher.png new file mode 100644 index 0000000..64d18bf Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher_round.png new file mode 100644 index 0000000..90bfe74 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_compose_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_launcher.png b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..2c7df4c Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..5aea0c0 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher.png b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher.png new file mode 100644 index 0000000..df2fc2f Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher_round.png new file mode 100644 index 0000000..70cfdc1 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_compose_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_launcher.png b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..aa9c115 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..85f4ace Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_compose_launcher.png b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_compose_launcher.png new file mode 100644 index 0000000..799e756 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_compose_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_compose_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_compose_launcher_round.png new file mode 100644 index 0000000..7ff1a87 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_compose_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..c0e0682 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..969e3a6 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher.png b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher.png new file mode 100644 index 0000000..6ec9bca Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher_round.png new file mode 100644 index 0000000..df16be9 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_compose_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ce478dc Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..c4098f9 Binary files /dev/null and b/hilt/hilt-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/hilt/hilt-app/src/main/res/values-night/themes.xml b/hilt/hilt-app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..4e197d3 --- /dev/null +++ b/hilt/hilt-app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/values/colors.xml b/hilt/hilt-app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/hilt/hilt-app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/values/dimens.xml b/hilt/hilt-app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..67abbea --- /dev/null +++ b/hilt/hilt-app/src/main/res/values/dimens.xml @@ -0,0 +1,11 @@ + + + 24dp + 56dp + 16dp + 8dp + 120dp + 6dp + 48dp + 12dp + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/values/strings.xml b/hilt/hilt-app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1192bd3 --- /dev/null +++ b/hilt/hilt-app/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + Hilt Test ShowCase + Hilt Compose Test ShowCase + Login + Username + Password + Username is not filled properly! + Password is not filled properly! + No User with given credentials! + Something went wrong! + Mock Login + Content + Logout + \ No newline at end of file diff --git a/hilt/hilt-app/src/main/res/values/themes.xml b/hilt/hilt-app/src/main/res/values/themes.xml new file mode 100644 index 0000000..64b01cd --- /dev/null +++ b/hilt/hilt-app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt new file mode 100644 index 0000000..d90bfb7 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/HttpsConfigurationModule.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient +import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier +import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor +import org.fnives.test.showcase.hilt.network.testutil.HttpsConfigurationModuleTemplate +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BindsBaseOkHttpClient::class] +) +object HttpsConfigurationModule { + + @Provides + @Singleton + @SessionLessQualifier + fun bindsBaseOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) = + HttpsConfigurationModuleTemplate.bindsBaseOkHttpClient(enableLogging, platformInterceptor) +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt new file mode 100644 index 0000000..cde8d0e --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestBaseUrlModule.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.hilt.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [BaseUrlModule::class] +) +object TestBaseUrlModule { + + @Provides + fun provideBaseUrl(): String = TestBaseUrlHolder.url +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt new file mode 100644 index 0000000..687707e --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/di/TestDatabaseInitializationModule.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.hilt.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import org.fnives.test.showcase.hilt.storage.LocalDatabase +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [StorageModule::class] +) +object TestDatabaseInitializationModule { + + @Singleton + @Provides + fun provideLocalDatabase(@ApplicationContext context: Context): LocalDatabase = + TestDatabaseInitialization.provideLocalDatabase(context) +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/UserDataLocalStorageTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/UserDataLocalStorageTest.kt new file mode 100644 index 0000000..ab38f20 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/UserDataLocalStorageTest.kt @@ -0,0 +1,73 @@ +package org.fnives.test.showcase.hilt.storage + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.fnives.test.showcase.hilt.core.integration.fake.FakeUserDataLocalStorage +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext.stopKoin +import org.koin.test.KoinTest +import org.robolectric.ParameterizedRobolectricTestRunner + +@RunWith(ParameterizedRobolectricTestRunner::class) +class UserDataLocalStorageTest( + private val userDataLocalStorageFactory: () -> UserDataLocalStorage +) : KoinTest { + + private lateinit var userDataLocalStorage: UserDataLocalStorage + + @Before + fun setup() { + userDataLocalStorage = userDataLocalStorageFactory.invoke() + } + + @After + fun tearDown() { + stopKoin() + } + + /** GIVEN session value WHEN accessed THEN it's returned **/ + @Test + fun sessionSetWillStayBeKept() { + val session = Session(accessToken = "a", refreshToken = "b") + userDataLocalStorage.session = session + + val actual = userDataLocalStorage.session + + Assert.assertEquals(session, actual) + } + + /** GIVEN null value WHEN accessed THEN it's null **/ + @Test + fun sessionSetToNullWillStayNull() { + userDataLocalStorage.session = Session(accessToken = "a", refreshToken = "b") + + userDataLocalStorage.session = null + val actual = userDataLocalStorage.session + + Assert.assertEquals(null, actual) + } + + companion object { + + private fun createFake(): UserDataLocalStorage = FakeUserDataLocalStorage() + + private fun createReal(): UserDataLocalStorage { + val context = ApplicationProvider.getApplicationContext() + + return SharedPreferencesManagerImpl.create(context) + } + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun userDataLocalStorageFactories(): List<() -> UserDataLocalStorage> = listOf( + ::createFake, + ::createReal + ) + } +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt new file mode 100644 index 0000000..b348cd9 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/favourite/FavouriteContentLocalStorageImplInstrumentedTest.kt @@ -0,0 +1,147 @@ +package org.fnives.test.showcase.hilt.storage.favourite + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.integration.fake.FakeFavouriteContentLocalStorage +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import org.fnives.test.showcase.model.content.ContentId +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.stopKoin +import org.robolectric.ParameterizedRobolectricTestRunner +import javax.inject.Inject + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(ParameterizedRobolectricTestRunner::class) +internal class FavouriteContentLocalStorageImplInstrumentedTest( + private val favouriteContentLocalStorageFactory: (FavouriteContentLocalStorage) -> FavouriteContentLocalStorage, +) { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + private lateinit var sut: FavouriteContentLocalStorage + private lateinit var testDispatcher: TestDispatcher + + @Inject + lateinit var real: FavouriteContentLocalStorage + + @Before + fun setUp() { + testDispatcher = StandardTestDispatcher() + TestDatabaseInitialization.dispatcher = testDispatcher + hiltRule.inject() + sut = favouriteContentLocalStorageFactory(real) + } + + @After + fun tearDown() { + stopKoin() + } + + /** GIVEN just created database WHEN querying THEN empty list is returned */ + @Test + fun atTheStartOurDatabaseIsEmpty() = runTest(testDispatcher) { + val actual = sut.observeFavourites().first() + + Assert.assertEquals(emptyList(), actual) + } + + /** GIVEN content_id WHEN added to Favourite THEN it can be read out */ + @Test + fun addingContentIdToFavouriteCanBeLaterReadOut() = runTest(testDispatcher) { + val expected = listOf(ContentId("a")) + + sut.markAsFavourite(ContentId("a")) + val actual = sut.observeFavourites().first() + + Assert.assertEquals(expected, actual) + } + + /** GIVEN content_id added WHEN removed to Favourite THEN it no longer can be read out */ + @Test + fun contentIdAddedThenRemovedCanNoLongerBeReadOut() = runTest(testDispatcher) { + val expected = listOf() + sut.markAsFavourite(ContentId("b")) + + sut.deleteAsFavourite(ContentId("b")) + val actual = sut.observeFavourites().first() + + Assert.assertEquals(expected, actual) + } + + /** GIVEN empty database WHILE observing content WHEN favourite added THEN change is emitted */ + @Test + fun addingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { + val expected = listOf(listOf(), listOf(ContentId("a"))) + val actual = async(coroutineContext) { sut.observeFavourites().take(2).toList() } + advanceUntilIdle() + + sut.markAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertEquals(expected, actual.getCompleted()) + } + + /** GIVEN non empty database WHILE observing content WHEN favourite removed THEN change is emitted */ + @Test + fun removingFavouriteUpdatesExistingObservers() = runTest(testDispatcher) { + val expected = listOf(listOf(ContentId("a")), listOf()) + sut.markAsFavourite(ContentId("a")) + + val actual = async(coroutineContext) { + sut.observeFavourites().take(2).toList() + } + advanceUntilIdle() + + sut.deleteAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertEquals(expected, actual.getCompleted()) + } + + /** GIVEN an observed WHEN adding and removing from it THEN we only get the expected amount of updates */ + @Test + fun noUnexpectedUpdates() = runTest(testDispatcher) { + val actual = async(coroutineContext) { sut.observeFavourites().take(4).toList() } + advanceUntilIdle() + + sut.markAsFavourite(ContentId("a")) + advanceUntilIdle() + sut.deleteAsFavourite(ContentId("a")) + advanceUntilIdle() + + Assert.assertFalse(actual.isCompleted) + actual.cancel() + } + + companion object { + + private fun createFake(): FavouriteContentLocalStorage = FakeFavouriteContentLocalStorage() + + private fun createReal(real: FavouriteContentLocalStorage): FavouriteContentLocalStorage = real + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun favouriteContentLocalStorageFactories(): List<(FavouriteContentLocalStorage) -> FavouriteContentLocalStorage> = listOf( + { createFake() }, + { createReal(it) } + ) + } +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt new file mode 100644 index 0000000..45ce6d9 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/storage/migration/MigrationToLatestInstrumentedTest.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.storage.migration + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fnives.test.showcase.hilt.test.shared.storage.migration.MigrationToLatestInstrumentedSharedTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MigrationToLatestInstrumentedTest : MigrationToLatestInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricAuthActivityInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricAuthActivityInstrumentedTest.kt new file mode 100644 index 0000000..1463aa9 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricAuthActivityInstrumentedTest.kt @@ -0,0 +1,173 @@ +package org.fnives.test.showcase.hilt.ui + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.fnives.test.showcase.android.testutil.activity.safeClose +import org.fnives.test.showcase.android.testutil.synchronization.MainDispatcherTestRule.Companion.advanceUntilIdleWithIdlingResources +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.CompositeDisposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.IdlingResourceDisposable +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.network.testutil.NetworkSynchronization +import org.fnives.test.showcase.hilt.test.shared.di.TestBaseUrlHolder +import org.fnives.test.showcase.hilt.test.shared.testutils.storage.TestDatabaseInitialization +import org.fnives.test.showcase.hilt.ui.auth.AuthActivity +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class RobolectricAuthActivityInstrumentedTest { + + private lateinit var activityScenario: ActivityScenario + private lateinit var robot: RobolectricLoginRobot + private lateinit var testDispatcher: TestDispatcher + private lateinit var mockServerScenarioSetup: MockServerScenarioSetup + private lateinit var disposable: Disposable + + @Inject + lateinit var networkSynchronization: NetworkSynchronization + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Before + fun setup() { + Intents.init() + val dispatcher = StandardTestDispatcher() + Dispatchers.setMain(dispatcher) + testDispatcher = dispatcher + TestDatabaseInitialization.dispatcher = dispatcher + + mockServerScenarioSetup = MockServerScenarioSetup() + TestBaseUrlHolder.url = mockServerScenarioSetup.start(false) + + hiltRule.inject() + val idlingResources = networkSynchronization.networkIdlingResources() + .map(::IdlingResourceDisposable) + disposable = CompositeDisposable(idlingResources) + + robot = RobolectricLoginRobot() + activityScenario = ActivityScenario.launch(AuthActivity::class.java) + activityScenario.moveToState(Lifecycle.State.RESUMED) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + mockServerScenarioSetup.stop() + disposable.dispose() + activityScenario.safeClose() + Intents.release() + } + + /** GIVEN non empty password and username and successful response WHEN signIn THEN no error is shown and navigating to home */ + @Test + fun properLoginResultsInNavigationToHome() { + mockServerScenarioSetup.setScenario( + AuthScenario.Success(password = "alma", username = "banan"), + validateArguments = true + ) + + robot.setPassword("alma") + .setUsername("banan") + .assertPassword("alma") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertNavigatedToHome() + } + + /** GIVEN empty password and username WHEN signIn THEN error password is shown */ + @Test + fun emptyPasswordShowsProperErrorMessage() { + robot.setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.password_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and empty username WHEN signIn THEN error username is shown */ + @Test + fun emptyUserNameShowsProperErrorMessage() { + robot.setPassword("banan") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.username_is_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and invalid credentials response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun invalidCredentialsGivenShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.InvalidCredentials(username = "alma", password = "banan"), + validateArguments = true + ) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.credentials_invalid) + .assertNotNavigatedToHome() + .assertNotLoading() + } + + /** GIVEN password and username and error response WHEN signIn THEN error invalid credentials is shown */ + @Test + fun networkErrorShowsProperErrorMessage() { + mockServerScenarioSetup.setScenario( + AuthScenario.GenericError(username = "alma", password = "banan"), + validateArguments = true + ) + robot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + .assertErrorIsNotShown() + + testDispatcher.advanceUntilIdleWithIdlingResources() + robot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotNavigatedToHome() + .assertNotLoading() + } +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricLoginRobot.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricLoginRobot.kt new file mode 100644 index 0000000..ad9b55a --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/RobolectricLoginRobot.kt @@ -0,0 +1,71 @@ +package org.fnives.test.showcase.hilt.ui + +import androidx.annotation.StringRes +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.fnives.test.showcase.android.testutil.intent.notIntended +import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsNotShown +import org.fnives.test.showcase.android.testutil.snackbar.SnackbarVerificationHelper.assertSnackBarIsShownWithText +import org.fnives.test.showcase.hilt.R +import org.fnives.test.showcase.hilt.ui.home.MainActivity +import org.hamcrest.core.IsNot.not + +class RobolectricLoginRobot { + + fun setUsername(username: String): RobolectricLoginRobot = apply { + onView(withId(R.id.user_edit_text)) + .perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard()) + } + + fun setPassword(password: String): RobolectricLoginRobot = apply { + onView(withId(R.id.password_edit_text)) + .perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard()) + } + + fun clickOnLogin() = apply { + onView(withId(R.id.login_cta)) + .perform(ViewActions.click()) + } + + fun assertPassword(password: String) = apply { + onView(withId((R.id.password_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(password))) + } + + fun assertUsername(username: String) = apply { + onView(withId((R.id.user_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(username))) + } + + fun assertLoadingBeforeRequests() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(isDisplayed())) + } + + fun assertNotLoading() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(not(isDisplayed()))) + } + + fun assertErrorIsShown(@StringRes stringResID: Int) = apply { + assertSnackBarIsShownWithText(stringResID) + } + + fun assertErrorIsNotShown() = apply { + assertSnackBarIsNotShown() + } + + fun assertNavigatedToHome() = apply { + intended(hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertNotNavigatedToHome() = apply { + notIntended(hasComponent(MainActivity::class.java.canonicalName)) + } +} diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt new file mode 100644 index 0000000..ebef811 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/auth/AuthActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.auth.AuthActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AuthActivityInstrumentedTest : AuthActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt new file mode 100644 index 0000000..e71b267 --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/home/MainActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.home + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.home.MainActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class MainActivityInstrumentedTest : MainActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt new file mode 100644 index 0000000..74b6ace --- /dev/null +++ b/hilt/hilt-app/src/robolectricTest/java/org/fnives/test/showcase/hilt/ui/splash/SplashActivityInstrumentedTest.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidTest +import org.fnives.test.showcase.hilt.test.shared.ui.splash.SplashActivityInstrumentedSharedTest +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class SplashActivityInstrumentedTest : SplashActivityInstrumentedSharedTest() diff --git a/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModelTest.kt b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModelTest.kt new file mode 100644 index 0000000..96c2d8f --- /dev/null +++ b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/auth/AuthViewModelTest.kt @@ -0,0 +1,216 @@ +package org.fnives.test.showcase.hilt.ui.auth + +import com.jraska.livedata.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.android.testutil.InstantExecutorExtension +import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher +import org.fnives.test.showcase.hilt.core.login.LoginUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import java.util.stream.Stream + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) +internal class AuthViewModelTest { + + private lateinit var sut: AuthViewModel + private lateinit var mockLoginUseCase: LoginUseCase + private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler + + @BeforeEach + fun setUp() { + mockLoginUseCase = mock() + sut = AuthViewModel(mockLoginUseCase) + } + + @DisplayName("GIVEN initialized viewModel WHEN observed THEN loading false other fields are empty") + @Test + fun initialSetup() { + val usernameTestObserver = sut.username.test() + val passwordTestObserver = sut.password.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + testScheduler.advanceUntilIdle() + + usernameTestObserver.assertNoValue() + passwordTestObserver.assertNoValue() + loadingTestObserver.assertValue(false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertNoValue() + } + + @DisplayName("GIVEN password text WHEN onPasswordChanged is called THEN password livedata is updated") + @Test + fun whenPasswordChangedLiveDataIsUpdated() { + val usernameTestObserver = sut.username.test() + val passwordTestObserver = sut.password.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onPasswordChanged("a") + sut.onPasswordChanged("al") + testScheduler.advanceUntilIdle() + + usernameTestObserver.assertNoValue() + passwordTestObserver.assertValueHistory("a", "al") + loadingTestObserver.assertValue(false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertNoValue() + } + + @DisplayName("GIVEN username text WHEN onUsernameChanged is called THEN username livedata is updated") + @Test + fun whenUsernameChangedLiveDataIsUpdated() { + val usernameTestObserver = sut.username.test() + val passwordTestObserver = sut.password.test() + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onUsernameChanged("bla") + sut.onUsernameChanged("blabla") + testScheduler.advanceUntilIdle() + + usernameTestObserver.assertValueHistory("bla", "blabla") + passwordTestObserver.assertNoValue() + loadingTestObserver.assertValue(false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertNoValue() + } + + @DisplayName("GIVEN no password or username WHEN login is Called THEN empty credentials are used in usecase") + @Test + fun noPasswordUsesEmptyStringInLoginUseCase() { + val loadingTestObserver = sut.loading.test() + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + + sut.onLogin() + testScheduler.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @DisplayName("WHEN login is called twice before finishing THEN use case is only called once") + @Test + fun onlyOneLoginIsSentOutWhenClickingRepeatedly() { + runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) } + + sut.onLogin() + sut.onLogin() + testScheduler.advanceUntilIdle() + + runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @DisplayName("GIVEN password and username WHEN login is called THEN proper credentials are used in usecase") + @Test + fun argumentsArePassedProperlyToLoginUseCase() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + sut.onPasswordChanged("pass") + sut.onUsernameChanged("usr") + testScheduler.advanceUntilIdle() + + sut.onLogin() + testScheduler.advanceUntilIdle() + + runBlocking { + verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("usr", "pass")) + } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @DisplayName("GIVEN AnswerError WHEN login called THEN error is shown") + @Test + fun loginUnexpectedErrorResultsInErrorState() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onLogin() + testScheduler.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + errorTestObserver.assertValueHistory(Event(AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR)) + navigateToHomeTestObserver.assertNoValue() + } + + @MethodSource("loginErrorStatusesArguments") + @ParameterizedTest(name = "GIVEN answer success loginStatus {0} WHEN login called THEN error {1} is shown") + fun invalidStatusResultsInErrorState( + loginStatus: LoginStatus, + errorType: AuthViewModel.ErrorType, + ) { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(loginStatus)) + } + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onLogin() + testScheduler.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + errorTestObserver.assertValueHistory(Event(errorType)) + navigateToHomeTestObserver.assertNoValue() + } + + @DisplayName("GIVEN answer success and login status success WHEN login called THEN navigation event is sent") + @Test + fun successLoginResultsInNavigation() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(LoginStatus.SUCCESS)) + } + val loadingTestObserver = sut.loading.test() + val errorTestObserver = sut.error.test() + val navigateToHomeTestObserver = sut.navigateToHome.test() + + sut.onLogin() + testScheduler.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + errorTestObserver.assertNoValue() + navigateToHomeTestObserver.assertValueHistory(Event(Unit)) + } + + companion object { + + @JvmStatic + fun loginErrorStatusesArguments(): Stream = Stream.of( + Arguments.of(LoginStatus.INVALID_CREDENTIALS, AuthViewModel.ErrorType.INVALID_CREDENTIALS), + Arguments.of(LoginStatus.INVALID_PASSWORD, AuthViewModel.ErrorType.UNSUPPORTED_PASSWORD), + Arguments.of(LoginStatus.INVALID_USERNAME, AuthViewModel.ErrorType.UNSUPPORTED_USERNAME) + ) + } +} diff --git a/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/home/MainViewModelTest.kt b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/home/MainViewModelTest.kt new file mode 100644 index 0000000..c557a13 --- /dev/null +++ b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/home/MainViewModelTest.kt @@ -0,0 +1,253 @@ +package org.fnives.test.showcase.hilt.ui.home + +import com.jraska.livedata.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.android.testutil.InstantExecutorExtension +import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher +import org.fnives.test.showcase.hilt.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.hilt.core.content.FetchContentUseCase +import org.fnives.test.showcase.hilt.core.content.GetAllContentUseCase +import org.fnives.test.showcase.hilt.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) +internal class MainViewModelTest { + + private lateinit var sut: MainViewModel + private lateinit var mockGetAllContentUseCase: GetAllContentUseCase + private lateinit var mockLogoutUseCase: LogoutUseCase + private lateinit var mockFetchContentUseCase: FetchContentUseCase + private lateinit var mockAddContentToFavouriteUseCase: AddContentToFavouriteUseCase + private lateinit var mockRemoveContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase + private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler + + @BeforeEach + fun setUp() { + mockGetAllContentUseCase = mock() + mockLogoutUseCase = mock() + mockFetchContentUseCase = mock() + mockAddContentToFavouriteUseCase = mock() + mockRemoveContentFromFavouritesUseCase = mock() + sut = MainViewModel( + getAllContentUseCase = mockGetAllContentUseCase, + logoutUseCase = mockLogoutUseCase, + fetchContentUseCase = mockFetchContentUseCase, + addContentToFavouriteUseCase = mockAddContentToFavouriteUseCase, + removeContentFromFavouritesUseCase = mockRemoveContentFromFavouritesUseCase + ) + } + + @DisplayName("WHEN initialization THEN error false other states empty") + @Test + fun initialStateIsCorrect() { + sut.errorMessage.test().assertValue(false) + sut.content.test().assertNoValue() + sut.loading.test().assertNoValue() + sut.navigateToAuth.test().assertNoValue() + } + + @DisplayName("GIVEN initialized viewModel WHEN loading is returned THEN loading is shown") + @Test + fun loadingDataShowsInLoadingUIState() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() + + errorMessageTestObserver.assertValue(false) + contentTestObserver.assertNoValue() + loadingTestObserver.assertValue(true) + navigateToAuthTestObserver.assertNoValue() + } + + @DisplayName("GIVEN loading then data WHEN observing content THEN proper states are shown") + @Test + fun loadingThenLoadedDataResultsInProperUIStates() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading(), Resource.Success(emptyList()))) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false) + contentTestObserver.assertValueHistory(listOf()) + loadingTestObserver.assertValueHistory(true, false) + navigateToAuthTestObserver.assertNoValue() + } + + @DisplayName("GIVEN loading then error WHEN observing content THEN proper states are shown") + @Test + fun loadingThenErrorResultsInProperUIStates() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading(), Resource.Error(Throwable()))) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, true) + contentTestObserver.assertValueHistory(emptyList()) + loadingTestObserver.assertValueHistory(true, false) + navigateToAuthTestObserver.assertNoValue() + } + + @DisplayName("GIVEN loading then error then loading then data WHEN observing content THEN proper states are shown") + @Test + fun loadingThenErrorThenLoadingThenDataResultsInProperUIStates() { + val content = listOf( + FavouriteContent(Content(ContentId(""), "", "", ImageUrl("")), false) + ) + whenever(mockGetAllContentUseCase.get()).doReturn( + flowOf( + Resource.Loading(), + Resource.Error(Throwable()), + Resource.Loading(), + Resource.Success(content) + ) + ) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + val navigateToAuthTestObserver = sut.navigateToAuth.test() + testScheduler.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, true, false) + contentTestObserver.assertValueHistory(emptyList(), content) + loadingTestObserver.assertValueHistory(true, false, true, false) + navigateToAuthTestObserver.assertNoValue() + } + + @DisplayName("GIVEN loading viewModel WHEN refreshing THEN usecase is not called") + @Test + fun fetchIsIgnoredIfViewModelIsStillLoading() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onRefresh() + testScheduler.advanceUntilIdle() + + verifyNoInteractions(mockFetchContentUseCase) + } + + @DisplayName("GIVEN non loading viewModel WHEN refreshing THEN usecase is called") + @Test + fun fetchIsCalledIfViewModelIsLoaded() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onRefresh() + testScheduler.advanceUntilIdle() + + verify(mockFetchContentUseCase, times(1)).invoke() + verifyNoMoreInteractions(mockFetchContentUseCase) + } + + @DisplayName("GIVEN loading viewModel WHEN loging out THEN usecase is called") + @Test + fun loadingViewModelStillCalsLogout() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onLogout() + testScheduler.advanceUntilIdle() + + runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } + verifyNoMoreInteractions(mockLogoutUseCase) + } + + @DisplayName("GIVEN non loading viewModel WHEN loging out THEN usecase is called") + @Test + fun nonLoadingViewModelStillCalsLogout() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onLogout() + testScheduler.advanceUntilIdle() + + runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } + verifyNoMoreInteractions(mockLogoutUseCase) + } + + @DisplayName("GIVEN success content list viewModel WHEN toggling a nonexistent contentId THEN nothing happens") + @Test + fun interactionWithNonExistentContentIdIsIgnored() { + val contents = listOf( + FavouriteContent(Content(ContentId("a"), "", "", ImageUrl("")), false), + FavouriteContent(Content(ContentId("b"), "", "", ImageUrl("")), true) + ) + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("c")) + testScheduler.advanceUntilIdle() + + verifyNoInteractions(mockRemoveContentFromFavouritesUseCase) + verifyNoInteractions(mockAddContentToFavouriteUseCase) + } + + @DisplayName("GIVEN success content list viewModel WHEN toggling a favourite contentId THEN remove favourite usecase is called") + @Test + fun togglingFavouriteContentCallsRemoveFromFavourite() { + val contents = listOf( + FavouriteContent(Content(ContentId("a"), "", "", ImageUrl("")), false), + FavouriteContent(Content(ContentId("b"), "", "", ImageUrl("")), true) + ) + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("b")) + testScheduler.advanceUntilIdle() + + runBlocking { verify(mockRemoveContentFromFavouritesUseCase, times(1)).invoke(ContentId("b")) } + verifyNoMoreInteractions(mockRemoveContentFromFavouritesUseCase) + verifyNoInteractions(mockAddContentToFavouriteUseCase) + } + + @DisplayName("GIVEN success content list viewModel WHEN toggling a not favourite contentId THEN add favourite usecase is called") + @Test + fun togglingNonFavouriteContentCallsAddToFavourite() { + val contents = listOf( + FavouriteContent(Content(ContentId("a"), "", "", ImageUrl("")), false), + FavouriteContent(Content(ContentId("b"), "", "", ImageUrl("")), true) + ) + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) + sut.content.test() + testScheduler.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("a")) + testScheduler.advanceUntilIdle() + + verifyNoInteractions(mockRemoveContentFromFavouritesUseCase) + runBlocking { verify(mockAddContentToFavouriteUseCase, times(1)).invoke(ContentId("a")) } + verifyNoMoreInteractions(mockAddContentToFavouriteUseCase) + } +} diff --git a/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/shared/EventTest.kt b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/shared/EventTest.kt new file mode 100644 index 0000000..6e76fb5 --- /dev/null +++ b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/shared/EventTest.kt @@ -0,0 +1,53 @@ +package org.fnives.test.showcase.hilt.ui.shared + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +internal class EventTest { + + @DisplayName("GIVEN event WHEN consumed is called THEN value is returned") + @Test + fun consumedReturnsValue() { + val expected = "a" + + val actual = Event("a").consume() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN consumed event WHEN consumed is called THEN null is returned") + @Test + fun consumedEventReturnsNull() { + val expected: String? = null + val event = Event("a") + event.consume() + + val actual = event.consume() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN event WHEN peek is called THEN value is returned") + @Test + fun peekReturnsValue() { + val expected = "a" + + val actual = Event("a").peek() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN consumed event WHEN peek is called THEN value is returned") + @Test + fun consumedEventPeekedReturnsValue() { + val expected = "a" + val event = Event("a") + event.consume() + + val actual = event.peek() + + Assertions.assertEquals(expected, actual) + } +} diff --git a/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModelTest.kt b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModelTest.kt new file mode 100644 index 0000000..b0172ce --- /dev/null +++ b/hilt/hilt-app/src/test/java/org/fnives/test/showcase/hilt/ui/splash/SplashViewModelTest.kt @@ -0,0 +1,63 @@ +package org.fnives.test.showcase.hilt.ui.splash + +import com.jraska.livedata.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.fnives.test.showcase.android.testutil.InstantExecutorExtension +import org.fnives.test.showcase.android.testutil.StandardTestMainDispatcher +import org.fnives.test.showcase.hilt.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.hilt.ui.shared.Event +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@ExtendWith(InstantExecutorExtension::class, StandardTestMainDispatcher::class) +@OptIn(ExperimentalCoroutinesApi::class) +internal class SplashViewModelTest { + + private lateinit var mockIsUserLoggedInUseCase: IsUserLoggedInUseCase + private lateinit var sut: SplashViewModel + private val testScheduler get() = StandardTestMainDispatcher.testDispatcher.scheduler + + @BeforeEach + fun setUp() { + mockIsUserLoggedInUseCase = mock() + sut = SplashViewModel(mockIsUserLoggedInUseCase) + } + + @DisplayName("GIVEN not logged in user WHEN splash started THEN after half a second navigated to authentication") + @Test + fun loggedOutUserGoesToAuthentication() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + val navigateToTestObserver = sut.navigateTo.test() + + testScheduler.advanceTimeBy(501) + + navigateToTestObserver.assertValueHistory(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) + } + + @DisplayName("GIVEN logged in user WHEN splash started THEN after half a second navigated to home") + @Test + fun loggedInUserGoesToHome() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true) + val navigateToTestObserver = sut.navigateTo.test() + + testScheduler.advanceTimeBy(501) + + navigateToTestObserver.assertValueHistory(Event(SplashViewModel.NavigateTo.HOME)) + } + + @DisplayName("GIVEN not logged in user WHEN splash started THEN before half a second no event is sent") + @Test + fun withoutEnoughTimeNoNavigationHappens() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + val navigateToTestObserver = sut.navigateTo.test() + + testScheduler.advanceTimeBy(500) + + navigateToTestObserver.assertNoValue() + } +} diff --git a/hilt/hilt-app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/hilt/hilt-app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/hilt/hilt-app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/hilt/hilt-app/src/test/resources/robolectric.properties b/hilt/hilt-app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..e5adbbb --- /dev/null +++ b/hilt/hilt-app/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +sdk=22,28 +instrumentedPackages=androidx.loader.content +application = dagger.hilt.android.testing.HiltTestApplication \ No newline at end of file diff --git a/hilt/hilt-core/.gitignore b/hilt/hilt-core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-core/build.gradle b/hilt/hilt-core/build.gradle new file mode 100644 index 0000000..4ddaa58 --- /dev/null +++ b/hilt/hilt-core/build.gradle @@ -0,0 +1,35 @@ +plugins { + id 'java-library' + id 'kotlin' + id 'kotlin-kapt' + id 'java-test-fixtures' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +kapt { + correctErrorTypes = true +} + +dependencies { + api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + + api project(":model") + implementation project(":hilt:hilt-network") + + applyCoreTestDependenciesTo(this) + + // hilt + implementation "com.google.dagger:hilt-core:$hilt_version" + kapt "com.google.dagger:hilt-compiler:$hilt_version" + def reloadable_module_version = "0.1.0" + implementation "org.fnives.library.reloadable.module:annotation:$reloadable_module_version" + kapt "org.fnives.library.reloadable.module:annotation-processor:$reloadable_module_version" + + testImplementation project(':mockserver') + testFixturesApi testFixtures(project(":hilt:hilt-network")) + kaptTest "com.google.dagger:dagger-compiler:$hilt_version" +} \ No newline at end of file diff --git a/hilt/hilt-core/consumer-rules.pro b/hilt/hilt-core/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-core/proguard-rules.pro b/hilt/hilt-core/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-core/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCase.kt new file mode 100644 index 0000000..5363bae --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCase.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.core.content + +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import javax.inject.Inject + +class AddContentToFavouriteUseCase @Inject internal constructor( + private val favouriteContentLocalStorage: FavouriteContentLocalStorage, +) { + + suspend fun invoke(contentId: ContentId) = + favouriteContentLocalStorage.markAsFavourite(contentId) +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/ContentRepository.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/ContentRepository.kt new file mode 100644 index 0000000..f958f2b --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/ContentRepository.kt @@ -0,0 +1,41 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.fnives.test.showcase.hilt.core.di.LoggedInModuleInject +import org.fnives.test.showcase.hilt.core.shared.Optional +import org.fnives.test.showcase.hilt.core.shared.mapIntoResource +import org.fnives.test.showcase.hilt.core.shared.wrapIntoAnswer +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSource +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.shared.Resource + +internal class ContentRepository @LoggedInModuleInject internal constructor( + private val contentRemoteSource: ContentRemoteSource, +) { + + private val mutableContentFlow = MutableStateFlow(Optional>(null)) + private val requestFlow: Flow>> = flow { + emit(Resource.Loading()) + val response = wrapIntoAnswer { contentRemoteSource.get() }.mapIntoResource() + if (response is Resource.Success) { + mutableContentFlow.value = Optional(response.data) + } + emit(response) + } + + @OptIn(ExperimentalCoroutinesApi::class) + val contents: Flow>> = mutableContentFlow.flatMapLatest { + if (it.item != null) flowOf(Resource.Success(it.item)) else requestFlow + } + .distinctUntilChanged() + + fun fetch() { + mutableContentFlow.value = Optional(null) + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCase.kt new file mode 100644 index 0000000..3e61801 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCase.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.core.content + +import javax.inject.Inject + +class FetchContentUseCase @Inject internal constructor(private val contentRepository: ContentRepository) { + + fun invoke() = contentRepository.fetch() +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCase.kt new file mode 100644 index 0000000..5315459 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCase.kt @@ -0,0 +1,47 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.shared.Resource +import javax.inject.Inject + +class GetAllContentUseCase @Inject internal constructor( + private val contentRepository: ContentRepository, + private val favouriteContentLocalStorage: FavouriteContentLocalStorage, +) { + + fun get(): Flow>> = + contentRepository.contents.combine( + favouriteContentLocalStorage.observeFavourites(), + ::combineContentWithFavourites + ) + .distinctUntilChanged() + + companion object { + private fun combineContentWithFavourites( + contentResource: Resource>, + favouriteContents: List, + ): Resource> = + when (contentResource) { + is Resource.Error -> Resource.Error(contentResource.error) + is Resource.Loading -> Resource.Loading() + is Resource.Success -> + Resource.Success( + combineContentWithFavourites(contentResource.data, favouriteContents) + ) + } + + private fun combineContentWithFavourites( + content: List, + favourite: List, + ): List = + content.map { + FavouriteContent(content = it, isFavourite = favourite.contains(it.id)) + } + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCase.kt new file mode 100644 index 0000000..7d8b75d --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCase.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.hilt.core.content + +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import javax.inject.Inject + +class RemoveContentFromFavouritesUseCase @Inject internal constructor( + private val favouriteContentLocalStorage: FavouriteContentLocalStorage, +) { + + suspend fun invoke(contentId: ContentId) { + favouriteContentLocalStorage.deleteAsFavourite(contentId) + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/CoreModule.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/CoreModule.kt new file mode 100644 index 0000000..b01fbb3 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/CoreModule.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.hilt.core.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.fnives.test.showcase.hilt.core.login.LogoutUseCase +import org.fnives.test.showcase.hilt.core.session.SessionExpirationAdapter +import org.fnives.test.showcase.hilt.core.storage.NetworkSessionLocalStorageAdapter +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage + +@InstallIn(SingletonComponent::class) +@Module +object CoreModule { + + @Provides + internal fun bindNetworkSessionLocalStorageAdapter( + networkSessionLocalStorageAdapter: NetworkSessionLocalStorageAdapter + ): NetworkSessionLocalStorage = networkSessionLocalStorageAdapter + + @Provides + internal fun bindNetworkSessionExpirationListener( + sessionExpirationAdapter: SessionExpirationAdapter + ): NetworkSessionExpirationListener = sessionExpirationAdapter + + @Provides + fun provideLogoutUseCase( + storage: UserDataLocalStorage, + reloadLoggedInModuleInjectModule: ReloadLoggedInModuleInjectModule + ): LogoutUseCase = LogoutUseCase(storage, reloadLoggedInModuleInjectModule) +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/LoggedInModuleInject.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/LoggedInModuleInject.kt new file mode 100644 index 0000000..c6cca05 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/di/LoggedInModuleInject.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.core.di + +import org.fnives.library.reloadable.module.annotation.ReloadableModule + +@ReloadableModule +@Target(AnnotationTarget.CONSTRUCTOR) +@Retention(AnnotationRetention.SOURCE) +annotation class LoggedInModuleInject diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCase.kt new file mode 100644 index 0000000..7d1470d --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCase.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.core.login + +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import javax.inject.Inject + +class IsUserLoggedInUseCase @Inject internal constructor( + private val userDataLocalStorage: UserDataLocalStorage, +) { + + fun invoke(): Boolean = userDataLocalStorage.session != null +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LoginUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LoginUseCase.kt new file mode 100644 index 0000000..e116006 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LoginUseCase.kt @@ -0,0 +1,31 @@ +package org.fnives.test.showcase.hilt.core.login + +import org.fnives.test.showcase.hilt.core.shared.wrapIntoAnswer +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSource +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import javax.inject.Inject + +class LoginUseCase @Inject internal constructor( + private val loginRemoteSource: LoginRemoteSource, + private val userDataLocalStorage: UserDataLocalStorage, +) { + + suspend fun invoke(credentials: LoginCredentials): Answer { + if (credentials.username.isBlank()) return Answer.Success(LoginStatus.INVALID_USERNAME) + if (credentials.password.isBlank()) return Answer.Success(LoginStatus.INVALID_PASSWORD) + + return wrapIntoAnswer { + when (val response = loginRemoteSource.login(credentials)) { + LoginStatusResponses.InvalidCredentials -> LoginStatus.INVALID_CREDENTIALS + is LoginStatusResponses.Success -> { + userDataLocalStorage.session = response.session + LoginStatus.SUCCESS + } + } + } + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCase.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCase.kt new file mode 100644 index 0000000..3e5aecd --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCase.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.hilt.core.login + +import org.fnives.test.showcase.hilt.core.di.ReloadLoggedInModuleInjectModule +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage + +class LogoutUseCase( + private val storage: UserDataLocalStorage, + private val reloadLoggedInModuleInjectModule: ReloadLoggedInModuleInjectModule, +) { + + suspend fun invoke() { + reloadLoggedInModuleInjectModule.reload() + storage.session = null + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapter.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapter.kt new file mode 100644 index 0000000..dcfb881 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapter.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.core.session + +import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener +import javax.inject.Inject + +internal class SessionExpirationAdapter @Inject constructor( + private val sessionExpirationListener: SessionExpirationListener +) : NetworkSessionExpirationListener { + + override fun onSessionExpired() = sessionExpirationListener.onSessionExpired() +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationListener.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationListener.kt new file mode 100644 index 0000000..057c913 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationListener.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.hilt.core.session + +interface SessionExpirationListener { + fun onSessionExpired() +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtils.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtils.kt new file mode 100644 index 0000000..a20edc0 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtils.kt @@ -0,0 +1,26 @@ +package org.fnives.test.showcase.hilt.core.shared + +import kotlinx.coroutines.CancellationException +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.model.shared.Resource + +@Suppress("RethrowCaughtException") +internal suspend fun wrapIntoAnswer(callback: suspend () -> T): Answer = + try { + Answer.Success(callback()) + } catch (networkException: NetworkException) { + Answer.Error(networkException) + } catch (parsingException: ParsingException) { + Answer.Error(parsingException) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (throwable: Throwable) { + Answer.Error(UnexpectedException(throwable)) + } + +internal fun Answer.mapIntoResource() = when (this) { + is Answer.Error -> Resource.Error(error) + is Answer.Success -> Resource.Success(data) +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/Optional.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/Optional.kt new file mode 100644 index 0000000..f680a47 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/Optional.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.hilt.core.shared + +internal class Optional(val item: T?) diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/UnexpectedException.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/UnexpectedException.kt new file mode 100644 index 0000000..ed24435 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/shared/UnexpectedException.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.core.shared + +class UnexpectedException(cause: Throwable) : RuntimeException(cause.message, cause) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return this.cause == (other as UnexpectedException).cause + } + + override fun hashCode(): Int = super.hashCode() + cause.hashCode() +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapter.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapter.kt new file mode 100644 index 0000000..9f884b6 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapter.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.core.storage + +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.model.session.Session +import javax.inject.Inject + +internal class NetworkSessionLocalStorageAdapter @Inject constructor( + private val userDataLocalStorage: UserDataLocalStorage, +) : NetworkSessionLocalStorage { + + override var session: Session? + get() = userDataLocalStorage.session + set(value) { + userDataLocalStorage.session = value + } +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/UserDataLocalStorage.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/UserDataLocalStorage.kt new file mode 100644 index 0000000..ee108fc --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/UserDataLocalStorage.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.hilt.core.storage + +import org.fnives.test.showcase.model.session.Session + +interface UserDataLocalStorage { + var session: Session? +} diff --git a/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/content/FavouriteContentLocalStorage.kt b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/content/FavouriteContentLocalStorage.kt new file mode 100644 index 0000000..3c6da86 --- /dev/null +++ b/hilt/hilt-core/src/main/java/org/fnives/test/showcase/hilt/core/storage/content/FavouriteContentLocalStorage.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.core.storage.content + +import kotlinx.coroutines.flow.Flow +import org.fnives.test.showcase.model.content.ContentId + +interface FavouriteContentLocalStorage { + + fun observeFavourites(): Flow> + + suspend fun markAsFavourite(contentId: ContentId) + + suspend fun deleteAsFavourite(contentId: ContentId) +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCaseTest.kt new file mode 100644 index 0000000..af4e3a7 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/AddContentToFavouriteUseCaseTest.kt @@ -0,0 +1,59 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class AddContentToFavouriteUseCaseTest { + + private lateinit var sut: AddContentToFavouriteUseCase + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + sut = AddContentToFavouriteUseCase(mockFavouriteContentLocalStorage) + } + + @DisplayName("WHEN nothing happens THEN the storage is not touched") + @Test + fun initializationDoesntAffectStorage() { + verifyNoInteractions(mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN contentId WHEN called THEN storage is called") + @Test + fun contentIdIsDelegatedToStorage() = runTest { + sut.invoke(ContentId("a")) + + verify(mockFavouriteContentLocalStorage, times(1)).markAsFavourite(ContentId("a")) + verifyNoMoreInteractions(mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN throwing local storage WHEN thrown THEN its propagated") + @Test + fun storageThrowingIsPropagated() = runTest { + whenever(mockFavouriteContentLocalStorage.markAsFavourite(ContentId("a"))).doThrow( + RuntimeException() + ) + + assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke(ContentId("a")) } + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/ContentRepositoryTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/ContentRepositoryTest.kt new file mode 100644 index 0000000..f998c47 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/ContentRepositoryTest.kt @@ -0,0 +1,152 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.shared.UnexpectedException +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSource +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class ContentRepositoryTest { + + private lateinit var sut: ContentRepository + private lateinit var mockContentRemoteSource: ContentRemoteSource + + @BeforeEach + fun setUp() { + mockContentRemoteSource = mock() + sut = ContentRepository(mockContentRemoteSource) + } + + @DisplayName("GIVEN no interaction THEN remote source is not called") + @Test + fun fetchingIsLazy() { + verifyNoMoreInteractions(mockContentRemoteSource) + } + + @DisplayName("GIVEN content response WHEN content observed THEN loading AND data is returned") + @Test + fun happyFlow() = runTest { + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(Content(ContentId("a"), "", "", ImageUrl("")))) + ) + whenever(mockContentRemoteSource.get()).doReturn( + listOf(Content(ContentId("a"), "", "", ImageUrl(""))) + ) + + val actual = sut.contents.take(2).toList() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN content error WHEN content observed THEN loading AND data is returned") + @Test + fun errorFlow() = runTest { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + whenever(mockContentRemoteSource.get()).doThrow(exception) + + val actual = sut.contents.take(2).toList() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN saved cache WHEN collected THEN cache is returned") + @Test + fun verifyCaching() = runTest { + val content = Content(ContentId("1"), "", "", ImageUrl("")) + val expected = listOf(Resource.Success(listOf(content))) + whenever(mockContentRemoteSource.get()).doReturn(listOf(content)) + sut.contents.take(2).toList() + + val actual = sut.contents.take(1).toList() + + verify(mockContentRemoteSource, times(1)).get() + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN no response from remote source WHEN content observed THEN loading is returned") + @Test + fun loadingIsShownBeforeTheRequestIsReturned() = runTest { + val expected = Resource.Loading>() + val suspendedRequest = CompletableDeferred() + whenever(mockContentRemoteSource.get()).doSuspendableAnswer { + suspendedRequest.await() + emptyList() + } + + val actual = sut.contents.take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + suspendedRequest.complete(Unit) + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN returned states are loading data loading error") + @Test + fun whenFetchingRequestIsCalledAgain() = runTest() { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + val actual = async { + sut.contents.take(4).toList() + } + advanceUntilIdle() + sut.fetch() + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") + @Test + fun noAdditionalItemsEmitted() = runTest { + val exception = RuntimeException() + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + val actual = async(coroutineContext) { sut.contents.take(5).toList() } + advanceUntilIdle() + sut.fetch() + advanceUntilIdle() + + Assertions.assertFalse(actual.isCompleted) + actual.cancel() + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCaseTest.kt new file mode 100644 index 0000000..5955698 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/FetchContentUseCaseTest.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class FetchContentUseCaseTest { + + private lateinit var sut: FetchContentUseCase + private lateinit var mockContentRepository: ContentRepository + + @BeforeEach + fun setUp() { + mockContentRepository = mock() + sut = FetchContentUseCase(mockContentRepository) + } + + @DisplayName("WHEN nothing happens THEN the storage is not touched") + @Test + fun initializationDoesntAffectRepository() { + verifyNoInteractions(mockContentRepository) + } + + @DisplayName("WHEN called THEN repository is called") + @Test + fun whenCalledRepositoryIsFetched() = runTest { + sut.invoke() + + verify(mockContentRepository, times(1)).fetch() + verifyNoMoreInteractions(mockContentRepository) + } + + @DisplayName("GIVEN throwing local storage WHEN thrown THEN its thrown") + @Test + fun whenRepositoryThrowsUseCaseAlsoThrows() = runTest { + whenever(mockContentRepository.fetch()).doThrow(RuntimeException()) + + assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke() } + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCaseTest.kt new file mode 100644 index 0000000..7932daf --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/GetAllContentUseCaseTest.kt @@ -0,0 +1,222 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class GetAllContentUseCaseTest { + + private lateinit var sut: GetAllContentUseCase + private lateinit var mockContentRepository: ContentRepository + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + private lateinit var contentFlow: MutableStateFlow>> + private lateinit var favouriteContentIdFlow: MutableStateFlow> + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + mockContentRepository = mock() + favouriteContentIdFlow = MutableStateFlow(emptyList()) + contentFlow = MutableStateFlow(Resource.Loading()) + whenever(mockFavouriteContentLocalStorage.observeFavourites()).doReturn( + favouriteContentIdFlow + ) + whenever(mockContentRepository.contents).doReturn(contentFlow) + sut = GetAllContentUseCase(mockContentRepository, mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN loading AND empty favourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithNoFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = emptyList() + contentFlow.value = Resource.Loading() + val expected = Resource.Loading>() + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN loading AND listOfFavourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + contentFlow.value = Resource.Loading() + val expected = Resource.Loading>() + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN error AND empty favourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithNoFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = emptyList() + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = Resource.Error>(exception) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN error AND listOfFavourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("b")) + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = Resource.Error>(exception) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN listOfContent AND empty favourite WHEN observed THEN favourites are returned") + @Test + fun successResourceWithNoFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = Resource.Success(items) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN listOfContent AND other favourite id WHEN observed THEN favourites are returned") + @Test + fun successResourceWithDifferentFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("x")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = Resource.Success(items) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN listOfContent AND same favourite id WHEN observed THEN favourites are returned") + @Test + fun successResourceWithSameFavouritesResultsInFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, true) + ) + val expected = Resource.Success(items) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @DisplayName("GIVEN loading then data then added favourite WHEN observed THEN loading then correct favourites are returned") + @Test + fun whileLoadingAndAddingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, false))), + Resource.Success(listOf(FavouriteContent(content, true))) + ) + + val actual = async(coroutineContext) { + sut.get().take(3).toList() + } + advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + advanceUntilIdle() + + favouriteContentIdFlow.value = listOf(ContentId("a")) + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) + } + + @DisplayName("GIVEN loading then data then removed favourite WHEN observed THEN loading then correct favourites are returned") + @Test + fun whileLoadingAndRemovingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Success(listOf(FavouriteContent(content, false))) + ) + + val actual = async(coroutineContext) { + sut.get().take(3).toList() + } + advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + advanceUntilIdle() + + favouriteContentIdFlow.value = emptyList() + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) + } + + @DisplayName("GIVEN loading then data then loading WHEN observed THEN loading then correct favourites then loading are returned") + @Test + fun loadingThenDataThenLoadingReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Loading() + ) + + val actual = async(coroutineContext) { + sut.get().take(3).toList() + } + advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + advanceUntilIdle() + + contentFlow.value = Resource.Loading() + advanceUntilIdle() + + Assertions.assertEquals(expected, actual.getCompleted()) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCaseTest.kt new file mode 100644 index 0000000..598a10f --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/RemoveContentFromFavouritesUseCaseTest.kt @@ -0,0 +1,57 @@ +package org.fnives.test.showcase.hilt.core.content + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class RemoveContentFromFavouritesUseCaseTest { + + private lateinit var sut: RemoveContentFromFavouritesUseCase + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + sut = RemoveContentFromFavouritesUseCase(mockFavouriteContentLocalStorage) + } + + @DisplayName("WHEN nothing happens THEN the storage is not touched") + @Test + fun initializationDoesntAffectStorage() { + verifyNoInteractions(mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN contentId WHEN called THEN storage is called") + @Test + fun givenContentIdCallsStorage() = runTest { + sut.invoke(ContentId("a")) + + verify(mockFavouriteContentLocalStorage, times(1)).deleteAsFavourite(ContentId("a")) + verifyNoMoreInteractions(mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN throwing local storage WHEN thrown THEN its propogated") + @Test + fun storageExceptionThrowingIsPropogated() = runTest { + whenever(mockFavouriteContentLocalStorage.deleteAsFavourite(ContentId("a"))).doThrow(RuntimeException()) + + Assertions.assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke(ContentId("a")) } + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineContentRepositoryTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineContentRepositoryTest.kt new file mode 100644 index 0000000..097b3f4 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineContentRepositoryTest.kt @@ -0,0 +1,167 @@ +package org.fnives.test.showcase.hilt.core.content + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.shared.UnexpectedException +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSource +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class TurbineContentRepositoryTest { + + private lateinit var sut: ContentRepository + private lateinit var mockContentRemoteSource: ContentRemoteSource + + @BeforeEach + fun setUp() { + mockContentRemoteSource = mock() + sut = ContentRepository(mockContentRemoteSource) + } + + @DisplayName("GIVEN no interaction THEN remote source is not called") + @Test + fun fetchingIsLazy() { + verifyNoMoreInteractions(mockContentRemoteSource) + } + + @DisplayName("GIVEN content response WHEN content observed THEN loading AND data is returned") + @Test + fun happyFlow() = runTest { + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(Content(ContentId("a"), "", "", ImageUrl("")))) + ) + whenever(mockContentRemoteSource.get()).doReturn( + listOf(Content(ContentId("a"), "", "", ImageUrl(""))) + ) + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN content error WHEN content observed THEN loading AND data is returned") + @Test + fun errorFlow() = runTest { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + whenever(mockContentRemoteSource.get()).doThrow(exception) + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN saved cache WHEN collected THEN cache is returned") + @Test + fun verifyCaching() = runTest { + val content = Content(ContentId("1"), "", "", ImageUrl("")) + val expected = listOf(Resource.Success(listOf(content))) + whenever(mockContentRemoteSource.get()).doReturn(listOf(content)) + sut.contents.take(2).toList() + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + verify(mockContentRemoteSource, times(1)).get() + } + + @DisplayName("GIVEN no response from remote source WHEN content observed THEN loading is returned") + @Test + fun loadingIsShownBeforeTheRequestIsReturned() = runTest { + val expected = listOf(Resource.Loading>()) + val suspendedRequest = CompletableDeferred() + whenever(mockContentRemoteSource.get()).doSuspendableAnswer { + suspendedRequest.await() + emptyList() + } + + sut.contents.test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + suspendedRequest.complete(Unit) + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN returned states are loading data loading error") + @Test + fun whenFetchingRequestIsCalledAgain() = runTest(UnconfinedTestDispatcher()) { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + sut.contents.test { + sut.fetch() + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + } + } + + @DisplayName("GIVEN content response THEN error WHEN fetched THEN only 4 items are emitted") + @Test + fun noAdditionalItemsEmitted() = runTest { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + sut.contents.test { + sut.fetch() + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineGetAllContentUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineGetAllContentUseCaseTest.kt new file mode 100644 index 0000000..5fa3847 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/content/TurbineGetAllContentUseCaseTest.kt @@ -0,0 +1,230 @@ +package org.fnives.test.showcase.hilt.core.content + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class TurbineGetAllContentUseCaseTest { + + private lateinit var sut: GetAllContentUseCase + private lateinit var mockContentRepository: ContentRepository + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + private lateinit var contentFlow: MutableStateFlow>> + private lateinit var favouriteContentIdFlow: MutableStateFlow> + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + mockContentRepository = mock() + favouriteContentIdFlow = MutableStateFlow(emptyList()) + contentFlow = MutableStateFlow(Resource.Loading()) + whenever(mockFavouriteContentLocalStorage.observeFavourites()).doReturn( + favouriteContentIdFlow + ) + whenever(mockContentRepository.contents).doReturn(contentFlow) + sut = GetAllContentUseCase(mockContentRepository, mockFavouriteContentLocalStorage) + } + + @DisplayName("GIVEN loading AND empty favourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithNoFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = emptyList() + contentFlow.value = Resource.Loading() + val expected = listOf(Resource.Loading>()) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading AND listOfFavourite WHEN observed THEN loading is shown") + @Test + fun loadingResourceWithFavouritesResultsInLoadingResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + contentFlow.value = Resource.Loading() + val expected = listOf(Resource.Loading>()) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN error AND empty favourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithNoFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = emptyList() + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = listOf(Resource.Error>(exception)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN error AND listOfFavourite WHEN observed THEN error is shown") + @Test + fun errorResourceWithFavouritesResultsInErrorResource() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("b")) + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = listOf(Resource.Error>(exception)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN listOfContent AND empty favourite WHEN observed THEN favourites are returned") + @Test + fun successResourceWithNoFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = listOf(Resource.Success(items)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN listOfContent AND other favourite id WHEN observed THEN favourites are returned") + @Test + fun successResourceWithDifferentFavouritesResultsInNoFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("x")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = listOf(Resource.Success(items)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN listOfContent AND same favourite id WHEN observed THEN favourites are returned") + @Test + fun successResourceWithSameFavouritesResultsInFavouritedItems() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, true) + ) + val expected = listOf(Resource.Success(items)) + + sut.get().test { + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading then data then added favourite WHEN observed THEN loading then correct favourites are returned") + @Test + fun whileLoadingAndAddingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, false))), + Resource.Success(listOf(FavouriteContent(content, true))) + ) + + sut.get().test { + contentFlow.value = Resource.Success(listOf(content)) + favouriteContentIdFlow.value = listOf(ContentId("a")) + + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading then data then removed favourite WHEN observed THEN loading then correct favourites are returned") + @Test + fun whileLoadingAndRemovingItemsReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Success(listOf(FavouriteContent(content, false))) + ) + + sut.get().test { + contentFlow.value = Resource.Success(listOf(content)) + favouriteContentIdFlow.value = emptyList() + + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } + + @DisplayName("GIVEN loading then data then loading WHEN observed THEN loading then correct favourites then loading are returned") + @Test + fun loadingThenDataThenLoadingReactsProperly() = runTest { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Loading() + ) + + sut.get().test { + contentFlow.value = Resource.Success(listOf(content)) + contentFlow.value = Resource.Loading() + + expected.forEach { expectedItem -> + Assertions.assertEquals(expectedItem, awaitItem()) + } + Assertions.assertTrue(cancelAndConsumeRemainingEvents().isEmpty()) + } + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/di/TestCoreComponent.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/di/TestCoreComponent.kt new file mode 100644 index 0000000..452a98c --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/di/TestCoreComponent.kt @@ -0,0 +1,35 @@ +package org.fnives.test.showcase.hilt.core.di + +import dagger.BindsInstance +import dagger.Component +import org.fnives.test.showcase.hilt.core.login.LogoutUseCaseTest +import org.fnives.test.showcase.hilt.core.session.SessionExpirationListener +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient +import org.fnives.test.showcase.hilt.network.di.HiltNetworkModule +import javax.inject.Singleton + +@Singleton +@Component(modules = [CoreModule::class, HiltNetworkModule::class, ReloadLoggedInModuleInjectModuleImpl::class, BindsBaseOkHttpClient::class]) +internal interface TestCoreComponent { + + @Component.Builder + interface Builder { + + @BindsInstance + fun setBaseUrl(baseUrl: String): Builder + + @BindsInstance + fun setEnableLogging(enableLogging: Boolean): Builder + + @BindsInstance + fun setSessionExpirationListener(listener: SessionExpirationListener): Builder + + @BindsInstance + fun setUserDataLocalStorage(storage: UserDataLocalStorage): Builder + + fun build(): TestCoreComponent + } + + fun inject(logoutUseCaseTest: LogoutUseCaseTest) +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCaseTest.kt new file mode 100644 index 0000000..b2dbed2 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/IsUserLoggedInUseCaseTest.kt @@ -0,0 +1,66 @@ +package org.fnives.test.showcase.hilt.core.login + +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class IsUserLoggedInUseCaseTest { + + private lateinit var sut: IsUserLoggedInUseCase + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + sut = IsUserLoggedInUseCase(mockUserDataLocalStorage) + } + + @DisplayName("WHEN nothing is called THEN storage is not called") + @Test + fun creatingDoesntAffectStorage() { + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN session data saved WHEN is user logged in checked THEN true is returned") + @Test + fun sessionInStorageResultsInLoggedIn() { + whenever(mockUserDataLocalStorage.session).doReturn(Session("a", "b")) + + val actual = sut.invoke() + + Assertions.assertEquals(true, actual) + } + + @DisplayName("GIVEN no session data saved WHEN is user logged in checked THEN false is returned") + @Test + fun noSessionInStorageResultsInLoggedOut() { + whenever(mockUserDataLocalStorage.session).doReturn(null) + + val actual = sut.invoke() + + Assertions.assertEquals(false, actual) + } + + @DisplayName("GIVEN no session THEN session THEN no session WHEN is user logged in checked over again THEN every return is correct") + @Test + fun multipleSessionSettingsResultsInCorrectResponses() { + whenever(mockUserDataLocalStorage.session).doReturn(null) + val actual1 = sut.invoke() + whenever(mockUserDataLocalStorage.session).doReturn(Session("", "")) + val actual2 = sut.invoke() + whenever(mockUserDataLocalStorage.session).doReturn(null) + val actual3 = sut.invoke() + + Assertions.assertEquals(false, actual1) + Assertions.assertEquals(true, actual2) + Assertions.assertEquals(false, actual3) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LoginUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LoginUseCaseTest.kt new file mode 100644 index 0000000..2cb7707 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LoginUseCaseTest.kt @@ -0,0 +1,105 @@ +package org.fnives.test.showcase.hilt.core.login + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.shared.UnexpectedException +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSource +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.model.shared.Answer +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class LoginUseCaseTest { + + private lateinit var sut: LoginUseCase + private lateinit var mockLoginRemoteSource: LoginRemoteSource + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockLoginRemoteSource = mock() + mockUserDataLocalStorage = mock() + sut = LoginUseCase(mockLoginRemoteSource, mockUserDataLocalStorage) + } + + @DisplayName("GIVEN empty username WHEN trying to login THEN invalid username is returned") + @Test + fun emptyUserNameReturnsLoginStatusError() = runTest { + val expected = Answer.Success(LoginStatus.INVALID_USERNAME) + + val actual = sut.invoke(LoginCredentials("", "a")) + + Assertions.assertEquals(expected, actual) + verifyNoInteractions(mockLoginRemoteSource) + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN empty password WHEN trying to login THEN invalid password is returned") + @Test + fun emptyPasswordNameReturnsLoginStatusError() = runTest { + val expected = Answer.Success(LoginStatus.INVALID_PASSWORD) + + val actual = sut.invoke(LoginCredentials("a", "")) + + Assertions.assertEquals(expected, actual) + verifyNoInteractions(mockLoginRemoteSource) + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN invalid credentials response WHEN trying to login THEN invalid credentials is returned ") + @Test + fun invalidLoginResponseReturnInvalidCredentials() = runTest { + val expected = Answer.Success(LoginStatus.INVALID_CREDENTIALS) + whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) + .doReturn(LoginStatusResponses.InvalidCredentials) + + val actual = sut.invoke(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN success response WHEN trying to login THEN session is saved and success is returned") + @Test + fun validResponseResultsInSavingSessionAndSuccessReturned() = runTest { + val expected = Answer.Success(LoginStatus.SUCCESS) + whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) + .doReturn(LoginStatusResponses.Success(Session("c", "d"))) + + val actual = sut.invoke(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + verify(mockUserDataLocalStorage, times(1)).session = Session("c", "d") + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN error response WHEN trying to login THEN session is not touched and error is returned") + @Test + fun invalidResponseResultsInErrorReturned() = runTest { + val exception = RuntimeException() + val expected = Answer.Error(UnexpectedException(exception)) + whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) + .doThrow(exception) + + val actual = sut.invoke(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + verifyNoInteractions(mockUserDataLocalStorage) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCaseTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCaseTest.kt new file mode 100644 index 0000000..964b9c5 --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/login/LogoutUseCaseTest.kt @@ -0,0 +1,71 @@ +package org.fnives.test.showcase.hilt.core.login + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.core.content.ContentRepository +import org.fnives.test.showcase.hilt.core.di.DaggerTestCoreComponent +import org.fnives.test.showcase.hilt.core.di.TestCoreComponent +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import javax.inject.Inject + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class LogoutUseCaseTest : KoinTest { + + @Inject + lateinit var sut: LogoutUseCase + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + private lateinit var testCoreComponent: TestCoreComponent + + @Inject + lateinit var contentRepository: ContentRepository + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + testCoreComponent = DaggerTestCoreComponent.builder() + .setBaseUrl("https://a.b.com") + .setEnableLogging(true) + .setSessionExpirationListener(mock()) + .setUserDataLocalStorage(mockUserDataLocalStorage) + .build() + testCoreComponent.inject(this) + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @DisplayName("WHEN no call THEN storage is not interacted") + @Test + fun initializedDoesntAffectStorage() { + verifyNoInteractions(mockUserDataLocalStorage) + } + + @DisplayName("WHEN logout invoked THEN storage is cleared") + @Test + fun logoutResultsInStorageCleaning() = runTest { + val repositoryBefore = contentRepository + + sut.invoke() + + testCoreComponent.inject(this@LogoutUseCaseTest) + val repositoryAfter = contentRepository + verify(mockUserDataLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockUserDataLocalStorage) + Assertions.assertNotSame(repositoryBefore, repositoryAfter) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapterTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapterTest.kt new file mode 100644 index 0000000..06d925b --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/session/SessionExpirationAdapterTest.kt @@ -0,0 +1,38 @@ +package org.fnives.test.showcase.hilt.core.session + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions + +@Suppress("TestFunctionName") +internal class SessionExpirationAdapterTest { + + private lateinit var sut: SessionExpirationAdapter + private lateinit var mockSessionExpirationListener: SessionExpirationListener + + @BeforeEach + fun setUp() { + mockSessionExpirationListener = mock() + sut = SessionExpirationAdapter(mockSessionExpirationListener) + } + + @DisplayName("WHEN nothing is changed THEN delegate is not touched") + @Test + fun verifyNoInteractionsIfNoInvocations() { + verifyNoInteractions(mockSessionExpirationListener) + } + + @DisplayName("WHEN onSessionExpired is called THEN delegated is also called") + @Test + fun verifyOnSessionExpirationIsDelegated() { + sut.onSessionExpired() + + verify(mockSessionExpirationListener, times(1)).onSessionExpired() + verifyNoMoreInteractions(mockSessionExpirationListener) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtilsKtTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtilsKtTest.kt new file mode 100644 index 0000000..ac3f96e --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/shared/AnswerUtilsKtTest.kt @@ -0,0 +1,90 @@ +package org.fnives.test.showcase.hilt.core.shared + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +internal class AnswerUtilsKtTest { + + @DisplayName("GIVEN network exception thrown WHEN wrapped into answer THEN answer error is returned") + @Test + fun networkExceptionThrownResultsInError() = runTest { + val exception = NetworkException(Throwable()) + val expected = Answer.Error(exception) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN parsing exception thrown WHEN wrapped into answer THEN answer error is returned") + @Test + fun parsingExceptionThrownResultsInError() = runTest { + val exception = ParsingException(Throwable()) + val expected = Answer.Error(exception) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN unexpected throwable thrown WHEN wrapped into answer THEN answer error is returned") + @Test + fun unexpectedExceptionThrownResultsInError() = runTest { + val exception = Throwable() + val expected = Answer.Error(UnexpectedException(exception)) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN string WHEN wrapped into answer THEN string answer is returned") + @Test + fun stringIsReturnedWrappedIntoSuccess() = runTest { + val expected = Answer.Success("banan") + + val actual = wrapIntoAnswer { "banan" } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN cancellation exception WHEN wrapped into answer THEN cancellation exception is thrown") + @Test + fun cancellationExceptionResultsInThrowingIt() { + Assertions.assertThrows(CancellationException::class.java) { + runBlocking { wrapIntoAnswer { throw CancellationException() } } + } + } + + @DisplayName("GIVEN success answer WHEN converted into resource THEN Resource success is returned") + @Test + fun successAnswerConvertsToSuccessResource() { + val expected = Resource.Success("alma") + + val actual = Answer.Success("alma").mapIntoResource() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN error answer WHEN converted into resource THEN Resource error is returned") + @Test + fun errorAnswerConvertsToErrorResource() { + val exception = Throwable() + val expected = Resource.Error(exception) + + val actual = Answer.Error(exception).mapIntoResource() + + Assertions.assertEquals(expected, actual) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapterTest.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapterTest.kt new file mode 100644 index 0000000..b6342cb --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/storage/NetworkSessionLocalStorageAdapterTest.kt @@ -0,0 +1,59 @@ +package org.fnives.test.showcase.hilt.core.storage + +import org.fnives.test.showcase.model.session.Session +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class NetworkSessionLocalStorageAdapterTest { + + private lateinit var sut: NetworkSessionLocalStorageAdapter + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + sut = NetworkSessionLocalStorageAdapter(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN null as session WHEN saved THEN its delegated") + @Test + fun settingNullSessionIsDelegated() { + sut.session = null + + verify(mockUserDataLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @DisplayName("GIVEN session WHEN saved THEN its delegated") + @Test + fun settingDataAsSessionIsDelegated() { + val expected = Session("a", "b") + + sut.session = Session("a", "b") + + verify(mockUserDataLocalStorage, times(1)).session = expected + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @DisplayName("WHEN session requested THEN its returned from delegated") + @Test + fun gettingSessionReturnsFromDelegate() { + val expected = Session("a", "b") + whenever(mockUserDataLocalStorage.session).doReturn(expected) + + val actual = sut.session + + Assertions.assertSame(expected, actual) + verify(mockUserDataLocalStorage, times(1)).session + verifyNoMoreInteractions(mockUserDataLocalStorage) + } +} diff --git a/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/testutil/AwaitElementEmitCount.kt b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/testutil/AwaitElementEmitCount.kt new file mode 100644 index 0000000..d03a24b --- /dev/null +++ b/hilt/hilt-core/src/test/java/org/fnives/test/showcase/hilt/core/testutil/AwaitElementEmitCount.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.hilt.core.testutil + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach + +class AwaitElementEmitCount(private var counter: Int) { + + private val completableDeferred = CompletableDeferred() + + init { + assert(counter > 0) + } + + fun attach(flow: Flow): Flow = + flow.onEach { + counter-- + if (counter == 0) { + completableDeferred.complete(Unit) + } + } + + suspend fun await() = completableDeferred.await() +} diff --git a/hilt/hilt-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/hilt/hilt-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/hilt/hilt-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeFavouriteContentLocalStorage.kt b/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeFavouriteContentLocalStorage.kt new file mode 100644 index 0000000..8674093 --- /dev/null +++ b/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeFavouriteContentLocalStorage.kt @@ -0,0 +1,30 @@ +package org.fnives.test.showcase.hilt.core.integration.fake + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.fnives.test.showcase.hilt.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId + +class FakeFavouriteContentLocalStorage : FavouriteContentLocalStorage { + + private val dataFlow = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + init { + dataFlow.tryEmit(emptyList()) + } + + override fun observeFavourites(): Flow> = dataFlow.asSharedFlow() + + override suspend fun markAsFavourite(contentId: ContentId) { + dataFlow.emit(dataFlow.replayCache.first().plus(contentId)) + } + + override suspend fun deleteAsFavourite(contentId: ContentId) { + dataFlow.emit(dataFlow.replayCache.first().minus(contentId)) + } +} diff --git a/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeUserDataLocalStorage.kt b/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeUserDataLocalStorage.kt new file mode 100644 index 0000000..1cb3d7b --- /dev/null +++ b/hilt/hilt-core/src/testFixtures/java/org/fnives/test/showcase/hilt/core/integration/fake/FakeUserDataLocalStorage.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.core.integration.fake + +import org.fnives.test.showcase.hilt.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session + +class FakeUserDataLocalStorage(override var session: Session? = null) : UserDataLocalStorage diff --git a/hilt/hilt-network-di-test-util/.gitignore b/hilt/hilt-network-di-test-util/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-network-di-test-util/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/build.gradle b/hilt/hilt-network-di-test-util/build.gradle new file mode 100644 index 0000000..5db617c --- /dev/null +++ b/hilt/hilt-network-di-test-util/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' +} + +android { + compileSdk 31 + + defaultConfig { + minSdk 21 + targetSdk 31 + + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + buildConfig = false + } +} + +// since it itself contains the TestUtil it doesn't have tests of it's own +disableTestTasks(this) + +dependencies { + implementation project(":hilt:hilt-network") + implementation "com.google.dagger:hilt-android-testing:$hilt_version" + implementation "com.squareup.retrofit2:retrofit:$retrofit_version" + implementation project(':mockserver') + implementation "androidx.test.espresso:espresso-core:$espresso_version" +} \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/consumer-rules.pro b/hilt/hilt-network-di-test-util/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-network-di-test-util/proguard-rules.pro b/hilt/hilt-network-di-test-util/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-network-di-test-util/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/src/main/AndroidManifest.xml b/hilt/hilt-network-di-test-util/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f356b0b --- /dev/null +++ b/hilt/hilt-network-di-test-util/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/HttpsConfigurationModuleTemplate.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/HttpsConfigurationModuleTemplate.kt new file mode 100644 index 0000000..a12603b --- /dev/null +++ b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/HttpsConfigurationModuleTemplate.kt @@ -0,0 +1,38 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import okhttp3.tls.HandshakeCertificates +import org.fnives.test.showcase.hilt.network.di.HiltNetworkModule +import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup + +// @Module +// @TestInstallIn( +// components = [SingletonComponent::class], +// replaces = [BindsBaseOkHttpClient::class] +// ) +object HttpsConfigurationModuleTemplate { + + lateinit var handshakeCertificates: HandshakeCertificates + +// @Provides +// @Singleton +// @SessionLessQualifier + fun bindsBaseOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) = + HiltNetworkModule.provideSessionLessOkHttpClient(enableLogging, platformInterceptor) + .newBuilder() + .sslSocketFactory( + handshakeCertificates.sslSocketFactory(), + handshakeCertificates.trustManager + ) + .build() + + fun startWithHTTPSMockWebServer(): Pair { + val mockServerScenarioSetup = MockServerScenarioSetup() + val url = mockServerScenarioSetup.start(true) + + handshakeCertificates = mockServerScenarioSetup.clientCertificates + ?: throw IllegalStateException("ClientCertificate should be accessable") + + return mockServerScenarioSetup to url + } +} diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt new file mode 100644 index 0000000..ffae218 --- /dev/null +++ b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt @@ -0,0 +1,37 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import androidx.annotation.CheckResult +import androidx.test.espresso.IdlingResource +import okhttp3.OkHttpClient +import org.fnives.test.showcase.hilt.network.di.SessionLessQualifier +import org.fnives.test.showcase.hilt.network.di.SessionQualifier +import javax.inject.Inject + +class NetworkSynchronization @Inject constructor( + @SessionQualifier + private val sessionOkhttpClient: OkHttpClient, + @SessionLessQualifier + private val sessionlessOkhttpClient: OkHttpClient +) { + + @CheckResult + fun networkIdlingResources(): List = + OkHttpClientTypes.values() + .map { it to getOkHttpClient(it) } + .associateBy { it.second.dispatcher } + .values + .map { (key, client) -> client.asIdlingResource(key.qualifier) } + + private fun getOkHttpClient(type: OkHttpClientTypes): OkHttpClient = + when (type) { + OkHttpClientTypes.SESSION -> sessionOkhttpClient + OkHttpClientTypes.SESSIONLESS -> sessionlessOkhttpClient + } + + private fun OkHttpClient.asIdlingResource(name: String): IdlingResource = + OkHttp3IdlingResource.create(name, this) + + enum class OkHttpClientTypes(val qualifier: String) { + SESSION("SESSION-NETWORKING"), SESSIONLESS("SESSIONLESS-NETWORKING") + } +} diff --git a/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt new file mode 100644 index 0000000..69423f2 --- /dev/null +++ b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/OkHttp3IdlingResource.kt @@ -0,0 +1,76 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import androidx.annotation.CheckResult +import androidx.annotation.NonNull +import androidx.test.espresso.IdlingResource +import okhttp3.Dispatcher +import okhttp3.OkHttpClient + +/** + * AndroidX version of Jake Wharton's OkHttp3IdlingResource. + * + * Reference: https://github.com/JakeWharton/okhttp-idling-resource/blob/master/src/main/java/com/jakewharton/espresso/OkHttp3IdlingResource.java + */ +class OkHttp3IdlingResource private constructor( + private val name: String, + private val dispatcher: Dispatcher +) : IdlingResource { + @Volatile + var callback: IdlingResource.ResourceCallback? = null + private var isIdleCallbackWasCalled: Boolean = true + + init { + val currentCallback = dispatcher.idleCallback + dispatcher.idleCallback = Runnable { + sleepForDispatcherDefaultCallInRetrofitErrorState() + callback?.onTransitionToIdle() + currentCallback?.run() + isIdleCallbackWasCalled = true + } + } + + override fun getName(): String = name + + override fun isIdleNow(): Boolean { + val isIdle = dispatcher.runningCallsCount() == 0 + if (isIdle) { + // sometime the callback is just not properly called it seems, or maybe sync error. + // if it isn't called Espresso crashes, so we add this here. + callback?.onTransitionToIdle() + } + return isIdle + } + + override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { + this.callback = callback + } + + companion object { + /** + * Create a new [IdlingResource] from `client` as `name`. You must register + * this instance using `Espresso.registerIdlingResources`. + */ + @CheckResult + @NonNull + fun create(@NonNull name: String?, @NonNull client: OkHttpClient?): OkHttp3IdlingResource { + if (name == null) throw NullPointerException("name == null") + if (client == null) throw NullPointerException("client == null") + return OkHttp3IdlingResource(name, client.dispatcher) + } + + /** + * This is required, because in case of Errors Retrofit uses Dispatcher.Default to suspendThrow + * see: retrofit2.KotlinExtensions.kt Exception.suspendAndThrow + * Relevant code issue: https://github.com/square/retrofit/blob/6cd6f7d8287f73909614cb7300fcde05f5719750/retrofit/src/main/java/retrofit2/KotlinExtensions.kt#L121 + * This is the current suggested approach to their problem with Unchecked Exceptions + * + * Sadly Dispatcher.Default cannot be replaced yet, so we can't swap it out in tests: + * https://github.com/Kotlin/kotlinx.coroutines/issues/1365 + * + * This brings us to this sleep for now. + */ + fun sleepForDispatcherDefaultCallInRetrofitErrorState() { + Thread.sleep(200L) + } + } +} diff --git a/hilt/hilt-network/.gitignore b/hilt/hilt-network/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/hilt/hilt-network/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/hilt/hilt-network/build.gradle b/hilt/hilt-network/build.gradle new file mode 100644 index 0000000..3f13d23 --- /dev/null +++ b/hilt/hilt-network/build.gradle @@ -0,0 +1,36 @@ +plugins { + id 'java-library' + id 'kotlin' + id 'kotlin-kapt' + id 'java-test-fixtures' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + + implementation "com.squareup.retrofit2:retrofit:$retrofit_version" + implementation "com.squareup.retrofit2:converter-moshi:$retrofit_version" + implementation "com.squareup.moshi:moshi:$moshi_version" + kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" + implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" + + api project(":model") + + applyNetworkTestDependenciesTo(this) + + // hilt + implementation "com.google.dagger:hilt-core:$hilt_version" + kapt "com.google.dagger:hilt-compiler:$hilt_version" + + kaptTest "com.google.dagger:dagger-compiler:$hilt_version" + + testFixturesApi project(':mockserver') + testFixturesApi "com.squareup.retrofit2:retrofit:$retrofit_version" + testFixturesImplementation "com.google.dagger:hilt-core:$hilt_version" + testFixturesApi "org.junit.jupiter:junit-jupiter-engine:$junit5_version" +} \ No newline at end of file diff --git a/hilt/hilt-network/consumer-rules.pro b/hilt/hilt-network/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/hilt/hilt-network/proguard-rules.pro b/hilt/hilt-network/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/hilt/hilt-network/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverter.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverter.kt new file mode 100644 index 0000000..c346b9c --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverter.kt @@ -0,0 +1,36 @@ +package org.fnives.test.showcase.hilt.network.auth + +import org.fnives.test.showcase.hilt.network.auth.model.LoginResponse +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.shared.ExceptionWrapper +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.session.Session +import retrofit2.HttpException +import retrofit2.Response +import javax.inject.Inject + +internal class LoginErrorConverter @Inject internal constructor() { + + @Throws(ParsingException::class) + suspend fun invoke(request: suspend () -> Response): LoginStatusResponses = + ExceptionWrapper.wrap { + val response = request() + if (response.code() == 400) { + return@wrap LoginStatusResponses.InvalidCredentials + } else if (!response.isSuccessful) { + throw HttpException(response) + } + + val parsedResponse = try { + response.body()!! + } catch (nullPointerException: NullPointerException) { + throw ParsingException(nullPointerException) + } + + val session = Session( + accessToken = parsedResponse.accessToken, + refreshToken = parsedResponse.refreshToken + ) + LoginStatusResponses.Success(session) + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSource.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSource.kt new file mode 100644 index 0000000..08b4466 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSource.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.network.auth + +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.auth.LoginCredentials + +interface LoginRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + suspend fun login(credentials: LoginCredentials): LoginStatusResponses +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceImpl.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceImpl.kt new file mode 100644 index 0000000..7fa250d --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceImpl.kt @@ -0,0 +1,28 @@ +package org.fnives.test.showcase.hilt.network.auth + +import org.fnives.test.showcase.hilt.network.auth.model.CredentialsRequest +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.shared.ExceptionWrapper +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.session.Session +import javax.inject.Inject + +internal class LoginRemoteSourceImpl @Inject internal constructor( + private val loginService: LoginService, + private val loginErrorConverter: LoginErrorConverter +) : LoginRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + override suspend fun login(credentials: LoginCredentials): LoginStatusResponses = + loginErrorConverter.invoke { + loginService.login(CredentialsRequest(user = credentials.username, password = credentials.password)) + } + + @Throws(NetworkException::class, ParsingException::class) + internal suspend fun refresh(refreshToken: String): Session = ExceptionWrapper.wrap { + val response = loginService.refreshToken(refreshToken) + Session(accessToken = response.accessToken, refreshToken = response.refreshToken) + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginService.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginService.kt new file mode 100644 index 0000000..0abe0d4 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/LoginService.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.hilt.network.auth + +import org.fnives.test.showcase.hilt.network.auth.model.CredentialsRequest +import org.fnives.test.showcase.hilt.network.auth.model.LoginResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path + +internal interface LoginService { + + @POST("login") + suspend fun login(@Body credentials: CredentialsRequest): Response + + @PUT("login/{token}") + suspend fun refreshToken(@Path("token") token: String): LoginResponse +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/CredentialsRequest.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/CredentialsRequest.kt new file mode 100644 index 0000000..eab25f8 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/CredentialsRequest.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.network.auth.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal class CredentialsRequest( + @Json(name = "username") + val user: String, + @Json(name = "password") + val password: String +) diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginResponse.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginResponse.kt new file mode 100644 index 0000000..c08439e --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginResponse.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.network.auth.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class LoginResponse( + @Json(name = "accessToken") + val accessToken: String, + @Json(name = "refreshToken") + val refreshToken: String +) diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginStatusResponses.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginStatusResponses.kt new file mode 100644 index 0000000..324d44a --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/auth/model/LoginStatusResponses.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.network.auth.model + +import org.fnives.test.showcase.model.session.Session + +sealed class LoginStatusResponses { + data class Success(val session: Session) : LoginStatusResponses() + object InvalidCredentials : LoginStatusResponses() +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSource.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSource.kt new file mode 100644 index 0000000..e3384fe --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSource.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.hilt.network.content + +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.content.Content + +interface ContentRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + suspend fun get(): List +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImpl.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImpl.kt new file mode 100644 index 0000000..450b899 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImpl.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.hilt.network.content + +import org.fnives.test.showcase.hilt.network.shared.ExceptionWrapper +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.ImageUrl +import javax.inject.Inject + +internal class ContentRemoteSourceImpl @Inject internal constructor( + private val contentService: ContentService +) : ContentRemoteSource { + + override suspend fun get(): List = + ExceptionWrapper.wrap { + contentService.getContent().mapNotNull(::mapResponse) + } + + companion object { + + private fun mapResponse(response: ContentResponse): Content? { + return Content( + id = response.id?.let(::ContentId) ?: return null, + title = response.title ?: return null, + description = response.description ?: return null, + imageUrl = ImageUrl(response.imageUrl ?: return null) + ) + } + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentResponse.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentResponse.kt new file mode 100644 index 0000000..00b8431 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentResponse.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.network.content + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class ContentResponse internal constructor( + @Json(name = "id") + val id: String?, + @Json(name = "title") + val title: String?, + @Json(name = "image") + val imageUrl: String?, + @Json(name = "says") + val description: String? +) diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentService.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentService.kt new file mode 100644 index 0000000..8bb1eef --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/content/ContentService.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.hilt.network.content + +import retrofit2.http.GET + +interface ContentService { + + @GET("content") + suspend fun getContent(): List +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/BindsBaseOkHttpClient.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/BindsBaseOkHttpClient.kt new file mode 100644 index 0000000..6b2721d --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/BindsBaseOkHttpClient.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.hilt.network.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient + +@InstallIn(SingletonComponent::class) +@Module +abstract class BindsBaseOkHttpClient { + + @Binds + @SessionLessQualifier + abstract fun bindsSessionLess(okHttpClient: OkHttpClient): OkHttpClient +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/HiltNetworkModule.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/HiltNetworkModule.kt new file mode 100644 index 0000000..198e516 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/HiltNetworkModule.kt @@ -0,0 +1,87 @@ +package org.fnives.test.showcase.hilt.network.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSource +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceImpl +import org.fnives.test.showcase.hilt.network.auth.LoginService +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSource +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSourceImpl +import org.fnives.test.showcase.hilt.network.content.ContentService +import org.fnives.test.showcase.hilt.network.session.AuthenticationHeaderInterceptor +import org.fnives.test.showcase.hilt.network.session.SessionAuthenticator +import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object HiltNetworkModule { + + @Provides + @Singleton + fun provideConverterFactory(): Converter.Factory = MoshiConverterFactory.create() + + @Provides + @Singleton + fun provideSessionLessOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) = + OkHttpClient.Builder() + .addInterceptor(platformInterceptor) + .setupLogging(enableLogging) + .build() + + @Provides + @Singleton + @SessionLessQualifier + fun provideSessionLessRetrofit( + baseUrl: String, + converterFactory: Converter.Factory, + @SessionLessQualifier okHttpClient: OkHttpClient, + ): Retrofit = + Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(converterFactory) + .client(okHttpClient) + .build() + + @Provides + @Singleton + @SessionQualifier + internal fun provideSessionOkHttpClient( + @SessionLessQualifier okHttpClient: OkHttpClient, + sessionAuthenticator: SessionAuthenticator, + authenticationHeaderInterceptor: AuthenticationHeaderInterceptor, + ) = + okHttpClient + .newBuilder() + .authenticator(sessionAuthenticator) + .addInterceptor(authenticationHeaderInterceptor) + .build() + + @Provides + @Singleton + @SessionQualifier + fun provideSessionRetrofit(@SessionLessQualifier retrofit: Retrofit, @SessionQualifier okHttpClient: OkHttpClient): Retrofit = + retrofit.newBuilder() + .client(okHttpClient) + .build() + + @Provides + internal fun bindContentRemoteSource(contentRemoteSourceImpl: ContentRemoteSourceImpl): ContentRemoteSource = contentRemoteSourceImpl + + @Provides + internal fun bindLoginRemoteSource(loginRemoteSource: LoginRemoteSourceImpl): LoginRemoteSource = loginRemoteSource + + @Provides + internal fun provideLoginService(@SessionLessQualifier retrofit: Retrofit): LoginService = + retrofit.create(LoginService::class.java) + + @Provides + internal fun provideContentService(@SessionQualifier retrofit: Retrofit): ContentService = + retrofit.create(ContentService::class.java) +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/OkhttpClientExtension.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/OkhttpClientExtension.kt new file mode 100644 index 0000000..0f717a7 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/OkhttpClientExtension.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.hilt.network.di + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor + +internal fun OkHttpClient.Builder.setupLogging(enable: Boolean) = run { + if (enable) { + addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }) + } else { + this + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionLessQualifier.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionLessQualifier.kt new file mode 100644 index 0000000..f56bec1 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionLessQualifier.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.network.di + +import javax.inject.Qualifier + +@Qualifier +annotation class SessionLessQualifier diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionQualifier.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionQualifier.kt new file mode 100644 index 0000000..dc36e91 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/di/SessionQualifier.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.network.di + +import javax.inject.Qualifier + +@Qualifier +annotation class SessionQualifier diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderInterceptor.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderInterceptor.kt new file mode 100644 index 0000000..4671960 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderInterceptor.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.network.session + +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +internal class AuthenticationHeaderInterceptor @Inject internal constructor( + private val authenticationHeaderUtils: AuthenticationHeaderUtils +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(authenticationHeaderUtils.attachToken(chain.request())) +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderUtils.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderUtils.kt new file mode 100644 index 0000000..5b323b4 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/AuthenticationHeaderUtils.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.hilt.network.session + +import okhttp3.Request +import javax.inject.Inject + +internal class AuthenticationHeaderUtils @Inject internal constructor( + private val networkSessionLocalStorage: NetworkSessionLocalStorage +) { + + fun hasToken(okhttpRequest: Request): Boolean = + okhttpRequest.header(KEY) == networkSessionLocalStorage.session?.accessToken + + fun attachToken(okhttpRequest: Request): Request = + okhttpRequest.newBuilder() + .header(KEY, networkSessionLocalStorage.session?.accessToken.orEmpty()).build() + + companion object { + private const val KEY = "Authorization" + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionExpirationListener.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionExpirationListener.kt new file mode 100644 index 0000000..7090556 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionExpirationListener.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.hilt.network.session + +interface NetworkSessionExpirationListener { + + fun onSessionExpired() +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionLocalStorage.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionLocalStorage.kt new file mode 100644 index 0000000..1dc937e --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/NetworkSessionLocalStorage.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.hilt.network.session + +import org.fnives.test.showcase.model.session.Session + +interface NetworkSessionLocalStorage { + + var session: Session? +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/SessionAuthenticator.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/SessionAuthenticator.kt new file mode 100644 index 0000000..4714566 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/session/SessionAuthenticator.kt @@ -0,0 +1,39 @@ +package org.fnives.test.showcase.hilt.network.session + +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceImpl +import javax.inject.Inject + +internal class SessionAuthenticator @Inject internal constructor( + private val networkSessionLocalStorage: NetworkSessionLocalStorage, + private val loginRemoteSource: LoginRemoteSourceImpl, + private val authenticationHeaderUtils: AuthenticationHeaderUtils, + private val networkSessionExpirationListener: NetworkSessionExpirationListener +) : Authenticator { + + @Suppress("SwallowedException") + override fun authenticate(route: Route?, response: Response): Request? { + if (authenticationHeaderUtils.hasToken(response.request)) { + return runBlocking { + try { + val refreshToken = networkSessionLocalStorage.session + ?.refreshToken + .orEmpty() + val newSession = loginRemoteSource.refresh(refreshToken) + networkSessionLocalStorage.session = newSession + return@runBlocking authenticationHeaderUtils.attachToken(response.request) + } catch (throwable: Throwable) { + networkSessionLocalStorage.session = null + networkSessionExpirationListener.onSessionExpired() + return@runBlocking null + } + } + } else { + return authenticationHeaderUtils.attachToken(response.request) + } + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/ExceptionWrapper.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/ExceptionWrapper.kt new file mode 100644 index 0000000..cc03d65 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/ExceptionWrapper.kt @@ -0,0 +1,28 @@ +package org.fnives.test.showcase.hilt.network.shared + +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonEncodingException +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import java.io.EOFException + +internal object ExceptionWrapper { + + @Suppress("RethrowCaughtException") + @Throws(NetworkException::class, ParsingException::class) + suspend fun wrap(request: suspend () -> T) = try { + request() + } catch (jsonDataException: JsonDataException) { + throw ParsingException(jsonDataException) + } catch (jsonEncodingException: JsonEncodingException) { + throw ParsingException(jsonEncodingException) + } catch (eofException: EOFException) { + throw ParsingException(eofException) + } catch (parsingException: ParsingException) { + throw parsingException + } catch (networkException: NetworkException) { + throw networkException + } catch (throwable: Throwable) { + throw NetworkException(throwable) + } +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/PlatformInterceptor.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/PlatformInterceptor.kt new file mode 100644 index 0000000..51ce022 --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/PlatformInterceptor.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.hilt.network.shared + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException +import javax.inject.Inject + +class PlatformInterceptor @Inject internal constructor() : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(chain.request().newBuilder().header("Platform", "Android").build()) +} diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/NetworkException.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/NetworkException.kt new file mode 100644 index 0000000..b21151e --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/NetworkException.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.hilt.network.shared.exceptions + +class NetworkException(cause: Throwable) : RuntimeException(cause.message, cause) diff --git a/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/ParsingException.kt b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/ParsingException.kt new file mode 100644 index 0000000..154705d --- /dev/null +++ b/hilt/hilt-network/src/main/java/org/fnives/test/showcase/hilt/network/shared/exceptions/ParsingException.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.hilt.network.shared.exceptions + +class ParsingException(cause: Throwable) : RuntimeException(cause.message, cause) diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverterTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverterTest.kt new file mode 100644 index 0000000..b47cef8 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginErrorConverterTest.kt @@ -0,0 +1,78 @@ +package org.fnives.test.showcase.hilt.network.auth + +import com.squareup.moshi.JsonDataException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.internal.http.RealResponseBody +import okio.Buffer +import org.fnives.test.showcase.hilt.network.auth.model.LoginResponse +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.model.session.Session +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import retrofit2.Response +import java.io.IOException + +@Suppress("TestFunctionName") +@OptIn(ExperimentalCoroutinesApi::class) +class LoginErrorConverterTest { + + private lateinit var sut: LoginErrorConverter + + @BeforeEach + fun setUp() { + sut = LoginErrorConverter() + } + + @DisplayName("GIVEN throwing lambda WHEN parsing login error THEN network exception is thrown") + @Test + fun generallyThrowingLambdaResultsInNetworkException() { + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { + sut.invoke { throw IOException() } + } + } + } + + @DisplayName("GIVEN jsonException throwing lambda WHEN parsing login error THEN network exception is thrown") + @Test + fun jsonDataThrowingLambdaResultsInParsingException() { + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { + sut.invoke { throw JsonDataException("") } + } + } + } + + @DisplayName("GIVEN 400 error response WHEN parsing login error THEN invalid credentials is returned") + @Test + fun code400ResponseResultsInInvalidCredentials() = runTest { + val expected = LoginStatusResponses.InvalidCredentials + + val actual = sut.invoke { + val responseBody = RealResponseBody(null, 0, Buffer()) + Response.error(400, responseBody) + } + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN successful response WHEN parsing login error THEN successful response is returned") + @Test + fun successResponseResultsInSessionResponse() = runTest { + val loginResponse = LoginResponse("a", "r") + val expectedSession = Session(accessToken = loginResponse.accessToken, refreshToken = loginResponse.refreshToken) + val expected = LoginStatusResponses.Success(expectedSession) + + val actual = sut.invoke { + Response.success(200, loginResponse) + } + + Assertions.assertEquals(expected, actual) + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceRefreshActionImplTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceRefreshActionImplTest.kt new file mode 100644 index 0000000..f206dc2 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceRefreshActionImplTest.kt @@ -0,0 +1,99 @@ +package org.fnives.test.showcase.hilt.network.auth + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent +import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.inject +import org.mockito.kotlin.mock +import javax.inject.Inject + +@Suppress("TestFunctionName") +class LoginRemoteSourceRefreshActionImplTest { + + @Inject + internal lateinit var sut: LoginRemoteSourceImpl + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + DaggerTestNetworkComponent.builder() + .setBaseUrl(mockServerScenarioSetupExtensions.url) + .setEnableLogging(true) + .setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage) + .setNetworkSessionExpirationListener(mock()) + .build() + .inject(this) + } + + @DisplayName("GIVEN successful response WHEN refresh request is fired THEN session is returned") + @Test + fun successResponseResultsInSession() = runBlocking { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, validateArguments = false) + val expected = ContentData.refreshSuccessResponse + + val actual = sut.refresh(ContentData.refreshSuccessResponse.refreshToken) + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN successful response WHEN refresh request is fired THEN the request is setup properly") + @Test + fun refreshRequestIsSetupProperly() = runBlocking { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, validateArguments = false) + + sut.refresh(ContentData.refreshSuccessResponse.refreshToken) + val request = mockServerScenarioSetup.takeRequest() + + Assertions.assertEquals("PUT", request.method) + Assertions.assertEquals("Android", request.getHeader("Platform")) + Assertions.assertEquals(null, request.getHeader("Authorization")) + Assertions.assertEquals("/login/${ContentData.refreshSuccessResponse.refreshToken}", request.path) + Assertions.assertEquals("", request.body.readUtf8()) + } + + @DisplayName("GIVEN internal error response WHEN refresh request is fired THEN network exception is thrown") + @Test + fun generalErrorResponseResultsInNetworkException() { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error, validateArguments = false) + + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.refresh(ContentData.refreshSuccessResponse.refreshToken) } + } + } + + @DisplayName("GIVEN invalid json response WHEN refresh request is fired THEN network exception is thrown") + @Test + fun jsonErrorResponseResultsInParsingException() { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.UnexpectedJsonAsSuccessResponse, validateArguments = false) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) } + } + } + + @DisplayName("GIVEN malformed json response WHEN refresh request is fired THEN parsing exception is thrown") + @Test + fun malformedJsonErrorResponseResultsInParsingException() { + mockServerScenarioSetup.setScenario(RefreshTokenScenario.MalformedJson, validateArguments = false) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) } + } + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceTest.kt new file mode 100644 index 0000000..9fec632 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/auth/LoginRemoteSourceTest.kt @@ -0,0 +1,146 @@ +package org.fnives.test.showcase.hilt.network.auth + +import com.squareup.moshi.JsonDataException +import kotlinx.coroutines.runBlocking +import okio.EOFException +import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent +import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.ContentData.createExpectedLoginRequestJson +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.mock +import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode +import retrofit2.HttpException +import javax.inject.Inject + +@Suppress("TestFunctionName") +class LoginRemoteSourceTest { + + @Inject + internal lateinit var sut: LoginRemoteSource + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + val mockNetworkSessionLocalStorage = mock() + DaggerTestNetworkComponent.builder() + .setBaseUrl(mockServerScenarioSetupExtensions.url) + .setEnableLogging(true) + .setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage) + .setNetworkSessionExpirationListener(mock()) + .build() + .inject(this) + } + + @DisplayName("GIVEN successful response WHEN request is fired THEN login status success is returned") + @Test + fun successResponseIsParsedProperly() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"), validateArguments = false) + val expected = LoginStatusResponses.Success(ContentData.loginSuccessResponse) + + val actual = sut.login(LoginCredentials(username = "a", password = "b")) + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN successful response WHEN request is fired THEN the request is setup properly") + @Test + fun requestProperlySetup() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"), validateArguments = false) + + sut.login(LoginCredentials(username = "a", password = "b")) + val request = mockServerScenarioSetup.takeRequest() + + Assertions.assertEquals("POST", request.method) + Assertions.assertEquals("Android", request.getHeader("Platform")) + Assertions.assertEquals(null, request.getHeader("Authorization")) + Assertions.assertEquals("/login", request.path) + val loginRequest = createExpectedLoginRequestJson(username = "a", password = "b") + JSONAssert.assertEquals( + loginRequest, + request.body.readUtf8(), + JSONCompareMode.NON_EXTENSIBLE + ) + } + + @DisplayName("GIVEN bad request response WHEN request is fired THEN login status invalid credentials is returned") + @Test + fun badRequestMeansInvalidCredentials() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials(username = "a", password = "b"), validateArguments = false) + val expected = LoginStatusResponses.InvalidCredentials + + val actual = sut.login(LoginCredentials(username = "a", password = "b")) + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN internal error response WHEN request is fired THEN network exception is thrown") + @Test + fun genericErrorMeansNetworkError() { + mockServerScenarioSetup.setScenario(AuthScenario.GenericError(username = "a", password = "b"), validateArguments = false) + + val actual = Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) } + } + + Assertions.assertEquals("HTTP 500 Server Error", actual.message) + Assertions.assertTrue(actual.cause is HttpException) + } + + @DisplayName("GIVEN invalid json response WHEN request is fired THEN network exception is thrown") + @Test + fun invalidJsonMeansParsingException() { + val response = AuthScenario.UnexpectedJsonAsSuccessResponse(username = "a", password = "b") + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + val actual = Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) } + } + + Assertions.assertEquals("Expected BEGIN_OBJECT but was BEGIN_ARRAY at path \$", actual.message) + Assertions.assertTrue(actual.cause is JsonDataException) + } + + @DisplayName("GIVEN json response with missing field WHEN request is fired THEN network exception is thrown") + @Test + fun missingFieldJsonMeansParsingException() { + val response = AuthScenario.MissingFieldJson(username = "a", password = "b") + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + val actual = Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) } + } + + Assertions.assertEquals("Required value 'accessToken' missing at \$", actual.message) + Assertions.assertTrue(actual.cause is JsonDataException) + } + + @DisplayName("GIVEN malformed json response WHEN request is fired THEN network exception is thrown") + @Test + fun malformedJsonMeansParsingException() { + val response = AuthScenario.MalformedJsonAsSuccessResponse(username = "a", "b") + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + val actual = Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials(username = "a", "b")) } + } + + Assertions.assertEquals("End of input", actual.message) + Assertions.assertTrue(actual.cause is EOFException) + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImplTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImplTest.kt new file mode 100644 index 0000000..3f364b8 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/ContentRemoteSourceImplTest.kt @@ -0,0 +1,123 @@ +package org.fnives.test.showcase.hilt.network.content + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException +import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent +import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.test.KoinTest +import org.koin.test.inject +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import javax.inject.Inject + +@Suppress("TestFunctionName") +class ContentRemoteSourceImplTest : KoinTest { + + @Inject + internal lateinit var sut: ContentRemoteSourceImpl + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + DaggerTestNetworkComponent.builder() + .setBaseUrl(mockServerScenarioSetupExtensions.url) + .setEnableLogging(true) + .setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage) + .setNetworkSessionExpirationListener(mock()) + .build() + .inject(this) + } + + @DisplayName("GIVEN successful response WHEN getting content THEN its parsed and returned correctly") + @Test + fun successResponseParsing() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false), validateArguments = false) + val expected = ContentData.contentSuccess + + val actual = sut.get() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN successful response WHEN getting content THEN the request is setup properly") + @Test + fun successResponseRequestIsCorrect() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false), validateArguments = false) + + sut.get() + val request = mockServerScenarioSetup.takeRequest() + + Assertions.assertEquals("GET", request.method) + Assertions.assertEquals("Android", request.getHeader("Platform")) + Assertions.assertEquals(ContentData.loginSuccessResponse.accessToken, request.getHeader("Authorization")) + Assertions.assertEquals("/content", request.path) + Assertions.assertEquals("", request.body.readUtf8()) + } + + @DisplayName("GIVEN response with missing Field WHEN getting content THEN invalid is ignored others are returned") + @Test + fun dataMissingFieldIsIgnored() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + val response = ContentScenario.SuccessWithMissingFields(usingRefreshedToken = false) + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + val expected = ContentData.contentSuccessWithMissingFields + + val actual = sut.get() + + Assertions.assertEquals(expected, actual) + } + + @DisplayName("GIVEN error response WHEN getting content THEN network request is thrown") + @Test + fun errorResponseResultsInNetworkException() { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false), validateArguments = false) + + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.get() } + } + } + + @DisplayName("GIVEN unexpected json response WHEN getting content THEN parsing request is thrown") + @Test + fun unexpectedJSONResultsInParsingException() { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + val response = ContentScenario.UnexpectedJsonAsSuccessResponse(usingRefreshedToken = false) + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.get() } + } + } + + @DisplayName("GIVEN malformed json response WHEN getting content THEN parsing request is thrown") + @Test + fun malformedJSONResultsInParsingException() { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + val response = ContentScenario.MalformedJsonAsSuccessResponse(usingRefreshedToken = false) + mockServerScenarioSetup.setScenario(response, validateArguments = false) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.get() } + } + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/SessionExpirationTest.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/SessionExpirationTest.kt new file mode 100644 index 0000000..4d38c35 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/content/SessionExpirationTest.kt @@ -0,0 +1,109 @@ +package org.fnives.test.showcase.hilt.network.content + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent +import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import retrofit2.HttpException +import javax.inject.Inject + +@Suppress("TestFunctionName") +class SessionExpirationTest { + + @Inject + internal lateinit var sut: ContentRemoteSourceImpl + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + private lateinit var mockNetworkSessionExpirationListener: NetworkSessionExpirationListener + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + mockNetworkSessionExpirationListener = mock() + DaggerTestNetworkComponent.builder() + .setBaseUrl(mockServerScenarioSetupExtensions.url) + .setEnableLogging(true) + .setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage) + .setNetworkSessionExpirationListener(mockNetworkSessionExpirationListener) + .build() + .inject(this) + } + + @DisplayName("GIVEN 401 THEN refresh token ok response WHEN content requested THE tokens are refreshed and request retried with new tokens") + @Test + fun successRefreshResultsInRequestRetry() = runBlocking { + var sessionToReturnByMock: Session? = ContentData.loginSuccessResponse + mockServerScenarioSetup.setScenario( + ContentScenario.Unauthorized(usingRefreshedToken = false) + .then(ContentScenario.Success(usingRefreshedToken = true)), + validateArguments = false + ) + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, validateArguments = false) + whenever(mockNetworkSessionLocalStorage.session).doAnswer { sessionToReturnByMock } + doAnswer { sessionToReturnByMock = it.arguments[0] as Session? } + .whenever(mockNetworkSessionLocalStorage).session = anyOrNull() + + sut.get() + + mockServerScenarioSetup.takeRequest() + val refreshRequest = mockServerScenarioSetup.takeRequest() + val retryAfterTokenRefreshRequest = mockServerScenarioSetup.takeRequest() + + Assertions.assertEquals("PUT", refreshRequest.method) + Assertions.assertEquals( + "/login/${ContentData.loginSuccessResponse.refreshToken}", + refreshRequest.path + ) + Assertions.assertEquals(null, refreshRequest.getHeader("Authorization")) + Assertions.assertEquals("Android", refreshRequest.getHeader("Platform")) + Assertions.assertEquals("", refreshRequest.body.readUtf8()) + Assertions.assertEquals( + ContentData.refreshSuccessResponse.accessToken, + retryAfterTokenRefreshRequest.getHeader("Authorization") + ) + verifyNoInteractions(mockNetworkSessionExpirationListener) + } + + @DisplayName("GIVEN 401 THEN failing refresh WHEN content requested THE error is returned and callback is Called") + @Test + fun failingRefreshResultsInSessionExpiration() = runBlocking { + whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse) + mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false), validateArguments = false) + mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error, validateArguments = false) + + val actual = Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.get() } + } + + Assertions.assertEquals("HTTP 401 Client Error", actual.message) + Assertions.assertTrue(actual.cause is HttpException) + verify(mockNetworkSessionLocalStorage, times(3)).session + verify(mockNetworkSessionLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockNetworkSessionLocalStorage) + verify(mockNetworkSessionExpirationListener, times(1)).onSessionExpired() + } +} diff --git a/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/testutil/TestNetworkComponent.kt b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/testutil/TestNetworkComponent.kt new file mode 100644 index 0000000..89c3c98 --- /dev/null +++ b/hilt/hilt-network/src/test/java/org/fnives/test/showcase/hilt/network/testutil/TestNetworkComponent.kt @@ -0,0 +1,44 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import dagger.BindsInstance +import dagger.Component +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceRefreshActionImplTest +import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceTest +import org.fnives.test.showcase.hilt.network.content.ContentRemoteSourceImplTest +import org.fnives.test.showcase.hilt.network.content.SessionExpirationTest +import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient +import org.fnives.test.showcase.hilt.network.di.HiltNetworkModule +import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage +import javax.inject.Singleton + +@Singleton +@Component(modules = [HiltNetworkModule::class, BindsBaseOkHttpClient::class]) +interface TestNetworkComponent { + + @Component.Builder + interface Builder { + + @BindsInstance + fun setBaseUrl(baseUrl: String): Builder + + @BindsInstance + fun setEnableLogging(enableLogging: Boolean): Builder + + @BindsInstance + fun setNetworkSessionLocalStorage(storage: NetworkSessionLocalStorage): Builder + + @BindsInstance + fun setNetworkSessionExpirationListener(listener: NetworkSessionExpirationListener): Builder + + fun build(): TestNetworkComponent + } + + fun inject(contentRemoteSourceImplTest: ContentRemoteSourceImplTest) + + fun inject(sessionExpirationTest: SessionExpirationTest) + + fun inject(loginRemoteSourceRefreshActionImplTest: LoginRemoteSourceRefreshActionImplTest) + + fun inject(loginRemoteSourceTest: LoginRemoteSourceTest) +} diff --git a/hilt/hilt-network/src/test/resources/success_response_login.json b/hilt/hilt-network/src/test/resources/success_response_login.json new file mode 100644 index 0000000..ba930ef --- /dev/null +++ b/hilt/hilt-network/src/test/resources/success_response_login.json @@ -0,0 +1,4 @@ +{ + "accessToken": "login-access", + "refreshToken": "login-refresh" +} \ No newline at end of file diff --git a/hilt/hilt-network/src/testFixtures/java/org/fnives/test/showcase/hilt/network/testutil/MockServerScenarioSetupExtensions.kt b/hilt/hilt-network/src/testFixtures/java/org/fnives/test/showcase/hilt/network/testutil/MockServerScenarioSetupExtensions.kt new file mode 100644 index 0000000..968d283 --- /dev/null +++ b/hilt/hilt-network/src/testFixtures/java/org/fnives/test/showcase/hilt/network/testutil/MockServerScenarioSetupExtensions.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.hilt.network.testutil + +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class MockServerScenarioSetupExtensions : BeforeEachCallback, AfterEachCallback { + + lateinit var url: String + lateinit var mockServerScenarioSetup: MockServerScenarioSetup + + override fun beforeEach(context: ExtensionContext?) { + mockServerScenarioSetup = MockServerScenarioSetup() + url = mockServerScenarioSetup.start(false) + } + + override fun afterEach(context: ExtensionContext?) { + mockServerScenarioSetup.stop() + } +} diff --git a/settings.gradle b/settings.gradle index 1306dfa..eb7803a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,5 +9,10 @@ include ':test-util-shared-robolectric' include ':test-util-android' include ':test-util-junit5-android' include ':app-shared-test' +include ':hilt:hilt-core' +include ':hilt:hilt-network' +include ':hilt:hilt-app' include ':examplecase:example-navcontroller' include ':examplecase:example-navcontroller-shared-test' +include ':hilt:hilt-network-di-test-util' +include ':hilt:hilt-app-shared-test'