diff --git a/.github/workflows/pull-request-jobs.yml b/.github/workflows/pull-request-jobs.yml index 6b087d8..920f129 100644 --- a/.github/workflows/pull-request-jobs.yml +++ b/.github/workflows/pull-request-jobs.yml @@ -75,7 +75,9 @@ jobs: if: always() with: name: JVM Test Results - path: ./**/build/reports/tests/**/*.html + path: | + ./**/build/reports/tests/**/*.html + ./**/**/build/reports/tests/**/*.html retention-days: 1 run-tests-on-emulator: @@ -126,7 +128,9 @@ jobs: if: always() with: name: Emulator-Test-Results-${{ matrix.api-level }} - path: ./**/build/reports/androidTests/**/*.html + path: | + ./**/build/reports/androidTests/**/*.html + ./**/**/build/reports/androidTests/**/*.html retention-days: 1 - name: Upload Test Screenshots uses: actions/upload-artifact@v2 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..7dbc095 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 { +abstract 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..e3fd1c2 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,12 +20,10 @@ 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 { +abstract 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..99ba24f 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,12 +15,10 @@ 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 { +abstract class AuthActivityInstrumentedSharedTest : KoinTest { private lateinit var activityScenario: ActivityScenario diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/codekata/CodeKataAuthActivitySharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/codekata/CodeKataAuthActivitySharedTest.kt index 07b66a0..eb94e63 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/codekata/CodeKataAuthActivitySharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/login/codekata/CodeKataAuthActivitySharedTest.kt @@ -14,7 +14,7 @@ import org.koin.test.KoinTest @OptIn(ExperimentalCoroutinesApi::class) @Ignore("CodeKata") @Suppress("EmptyFunctionBlock") -open class CodeKataAuthActivitySharedTest : KoinTest { +abstract class CodeKataAuthActivitySharedTest : KoinTest { @Before fun setup() { diff --git a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedSharedTest.kt b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedSharedTest.kt index f08761a..fbdde66 100644 --- a/app-shared-test/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedSharedTest.kt +++ b/app-shared-test/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivityInstrumentedSharedTest.kt @@ -18,7 +18,7 @@ import org.junit.rules.RuleChain import org.koin.test.KoinTest @Suppress("TestFunctionName") -open class SplashActivityInstrumentedSharedTest : KoinTest { +abstract class SplashActivityInstrumentedSharedTest : 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/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt index ac8f1b5..97287e5 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/AuthComposeInstrumentedTest.kt @@ -1,21 +1,18 @@ package org.fnives.test.showcase.ui -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 org.fnives.test.showcase.R 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.compose.screen.AppNavigation import org.fnives.test.showcase.core.integration.fake.FakeUserDataLocalStorage import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario import org.fnives.test.showcase.testutils.MockServerScenarioSetupResetingTestRule import org.fnives.test.showcase.testutils.idling.DatabaseDispatcherTestRule +import org.fnives.test.showcase.ui.compose.idle.ComposeNetworkSynchronizationTestRule import org.junit.Before import org.junit.Rule import org.junit.Test @@ -29,7 +26,9 @@ class AuthComposeInstrumentedTest : KoinTest { private val composeTestRule = createComposeRule() private val stateRestorationTester = StateRestorationTester(composeTestRule) - private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() + private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule( + networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule) + ) private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup private val dispatcherTestRule = DatabaseDispatcherTestRule() private lateinit var robot: ComposeLoginRobot @@ -72,7 +71,6 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() navigationRobot.assertHomeScreen() } @@ -86,7 +84,6 @@ class AuthComposeInstrumentedTest : KoinTest { .assertUsername("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -103,7 +100,6 @@ class AuthComposeInstrumentedTest : KoinTest { .assertPassword("banan") .clickOnLogin() - composeTestRule.mainClock.awaitIdlingResources() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -129,7 +125,6 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -155,7 +150,6 @@ class AuthComposeInstrumentedTest : KoinTest { robot.assertLoading() composeTestRule.mainClock.autoAdvance = true - composeTestRule.mainClock.awaitIdlingResources() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -181,15 +175,5 @@ class AuthComposeInstrumentedTest : KoinTest { 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/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt index 1791ed1..5c0d410 100644 --- a/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/ComposeLoginRobot.kt @@ -1,10 +1,10 @@ package org.fnives.test.showcase.ui import android.content.Context +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider 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 @@ -13,8 +13,8 @@ import androidx.test.core.app.ApplicationProvider import org.fnives.test.showcase.compose.screen.auth.AuthScreenTag class ComposeLoginRobot( - composeTestRule: ComposeTestRule, -) : ComposeTestRule by composeTestRule { + semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider, +) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt new file mode 100644 index 0000000..1f3efc0 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeIdlingDisposable.kt @@ -0,0 +1,22 @@ +package org.fnives.test.showcase.ui.compose.idle + +import androidx.compose.ui.test.IdlingResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable + +class ComposeIdlingDisposable( + private val idlingResource: IdlingResource, + private val testRule: ComposeTestRule, +) : Disposable { + override var isDisposed: Boolean = false + private set + + init { + testRule.registerIdlingResource(idlingResource) + } + + override fun dispose() { + isDisposed = true + testRule.unregisterIdlingResource(idlingResource) + } +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt new file mode 100644 index 0000000..7f68107 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/ComposeNetworkSynchronizationTestRule.kt @@ -0,0 +1,50 @@ +package org.fnives.test.showcase.ui.compose.idle + +import android.util.Log +import androidx.annotation.CheckResult +import androidx.compose.ui.test.junit4.ComposeTestRule +import okhttp3.OkHttpClient +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.OkHttp3IdlingResource +import org.fnives.test.showcase.network.testutil.NetworkTestConfigurationHelper +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import org.koin.test.KoinTest + +class ComposeNetworkSynchronizationTestRule(private val composeTestRule: ComposeTestRule) : TestRule, KoinTest { + + private var disposable: Disposable? = null + + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + override fun evaluate() { + disposable = registerIdlingResources() + try { + base.evaluate() + } finally { + dispose() + } + } + } + } + + fun dispose() { + if (disposable == null) { + Log.w("ComposeNetworkSynchronizationTestRule", "Was disposed, but registerIdlingResources was not called!") + } + disposable?.dispose() + } + + @CheckResult + private fun registerIdlingResources(): Disposable = getOkHttpClients() + .associateBy(keySelector = { it.toString() }) + .map { (key, client) -> OkHttp3IdlingResource.create(key, client) } + .map(::EspressoToComposeIdlingResourceAdapter) + .map { ComposeIdlingDisposable(it, composeTestRule) } + .let(::CompositeDisposable) + + private fun getOkHttpClients(): List = + NetworkTestConfigurationHelper.getOkHttpClients() +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt new file mode 100644 index 0000000..866595b --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.ui.compose.idle + +import androidx.test.espresso.IdlingResource + +class EspressoToComposeIdlingResourceAdapter(private val idlingResource: IdlingResource) : androidx.compose.ui.test.IdlingResource { + override val isIdleNow: Boolean get() = idlingResource.isIdleNow +} 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/codekata/compose.instructionset.md b/codekata/compose.instructionset.md index 5b461ba..8052c4f 100644 --- a/codekata/compose.instructionset.md +++ b/codekata/compose.instructionset.md @@ -22,8 +22,8 @@ Here is a list of actions we want to do: ```kotlin class ComposeLoginRobot( - composeTestRule: ComposeTestRule, -) : ComposeTestRule by composeTestRule { + semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider, +) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider { fun setUsername(username: String): ComposeLoginRobot = apply { onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username) @@ -60,11 +60,13 @@ class ComposeLoginRobot( } ``` -While in the View system we're using Espresso to interact with views, -in Compose we need a reference to the `ComposeTestRule` that contains our UI, +While in the View system we're using Espresso to interact with views, +in Compose we need a reference to the `SemanticsNodeInteractionsProvider` that contains our UI, which we will pass as a constructor parameter to the robot. -To create a `ComposeTestRule` you simply need to: +> SemanticsNodeInteractionsProvider gives access to `onNode` actions. ComposeTestRule extends it. + +To create a `ComposeTestRule` you simply need to: ```kotlin @get:Rule @@ -80,12 +82,12 @@ To add a tag to a composable use the `testTag` modifier in your UI, for example: Modifier.testTag(AuthScreenTag.UsernameInput) ``` -Once we have a node we can take actions such as `performClick()` or check assertions such as `assertTextContains`. +Once we have a node we can take actions such as `performClick()` or check assertions such as `assertTextContains`. For a list of finder, actions and assertions see the docs: https://developer.android.com/jetpack/compose/testing#testing-apis ##### Next up, we need to verify if we navigated: -If the navigation is also in compose we don't have an intent to check if we navigated. +If the navigation is also in compose we don't have an intent to check if we navigated. So instead, we're simply searching for regular composables that represent our destinations. This means that we could write a robot for our navigation which will simply check whether the root Composable for destination exists: @@ -102,7 +104,7 @@ This means that we could write a robot for our navigation which will simply chec ##### What about the Snackbar -Since everything in Compose is a composable, our Snackbar doesn't have anything special. +Since everything in Compose is a composable, our Snackbar doesn't have anything special. Put a tag on it and use the same finders and assertions. #### Test class setup @@ -111,7 +113,7 @@ The setup is the mostly the same as for View so for the sake of simplicity let's ##### Initializing the UI -We don't need an activity scenario. We will use instead `createComposeRule()` which will handle the host activity. +We don't need an activity scenario. We will use instead `createComposeRule()` which will handle the host activity. If you need a specific activity, use `createAndroidComposeRule()`. ```kotlin @@ -152,11 +154,15 @@ fun setup() { Network synchronization and mocking is the same as for View. ```kotlin -private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule() +private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule( + networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule) +) private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup ``` -Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need. +> ComposeNetworkSynchronizationTestRule is an equivalent to NetworkSynchronizationTestRule just registering the IdlingResource to ComposeTestRule instead of Espresso + +Coroutine setup is the same, except for `Dispatchers.setMain(dispatcher)`, which we don't need. ```kotlin private val dispatcherTestRule = DatabaseDispatcherTestRule() @@ -214,12 +220,13 @@ composeTestRule.mainClock.autoAdvance = true // Let clock auto advance again Lastly we check the navigation was correct, meaning we should be on the home screen: ```kotlin -composeTestRule.mainClock.awaitIdlingResources() // wait for login network call idling resource navigationRobot.assertHomeScreen() ``` -> `awaitIdlingResources` is an extension function to await all idling resources. -> Note: Considering what the docs say this shouldn't be necessarily if the idling resources are setup in Espresso, since the compose test rule is aware of espresso and it waits for idle before every finder. In practice it only works with the line above. Could be a bug somewhere. +> Note: Any node interactions call waitForIdle which waits for the Coroutine then the Network Call to finish. The Network call is running on OkHttps's own thread, so we use IdlingResources to synchronize with it. This is done in the ComposeNetworkSynchronizationTestRule. +> waitForIdle blocks the current thread while the Resources are busy. There is an alternative awaitIdle() which can be useful in runTest suspendable tests, feel free to look inside the Interface of ComposeTestRule. +> If you don't interact with a node but want to synchronize, then you will need waitForIdle. For example to verify something was called or written into like FakeLocalStorage in this example +> Basically since we have OkHttpIdlingResource as an EspressoIdlingResource we adapt that to Compose's IdlingResource class and register it with the ComposeTestRule and unregister it at the end. ### 2. `emptyPasswordShowsProperErrorMessage` @@ -240,7 +247,6 @@ robot.setUsername("banan") Finally we let coroutines go and verify the error is shown and we have not navigated: ```kotlin -composeTestRule.mainClock.awaitIdlingResources() robot.assertErrorIsShown(R.string.password_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -260,7 +266,6 @@ robot .assertPassword("banan") .clickOnLogin() -composeTestRule.mainClock.awaitIdlingResources() robot.assertErrorIsShown(R.string.username_is_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -293,7 +298,6 @@ composeTestRule.mainClock.autoAdvance = true Now at the end verify the error is shown properly: ```kotlin -composeTestRule.mainClock.awaitIdlingResources() robot.assertErrorIsShown(R.string.credentials_invalid) .assertNotLoading() navigationRobot.assertAuthScreen() @@ -323,7 +327,6 @@ composeTestRule.mainClock.advanceTimeByFrame() robot.assertLoading() composeTestRule.mainClock.autoAdvance = true -composeTestRule.mainClock.awaitIdlingResources() robot.assertErrorIsShown(R.string.something_went_wrong) .assertNotLoading() navigationRobot.assertAuthScreen() diff --git a/codekata/sharedtests.instructionset.md b/codekata/sharedtests.instructionset.md index 5bbb35c..b251e5b 100644 --- a/codekata/sharedtests.instructionset.md +++ b/codekata/sharedtests.instructionset.md @@ -39,7 +39,7 @@ Let's open `org.fnives.test.showcase.ui.login.codekata.CodeKataAuthActivityShare We can see it's identical as our original `org.fnives.test.showcase.ui.codekata.CodeKataAuthActivityInstrumentedTest`. So let's copy our existing code from the Robolectric test here. For that we can use the body of `org.fnives.test.showcase.ui.RobolectricAuthActivityInstrumentedTest`. -Of course keep the `open` and the `CodeKataAuthActivitySharedTest` class name and package. +Of course keep the `abstract`, the `CodeKataAuthActivitySharedTest` class name and package. We need to modify our robot: ```kotlin // Instead of this: 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..2f60e68 --- /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,80 @@ +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) +@Suppress("UnnecessaryAbstractClass") +abstract 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..79b4679 --- /dev/null +++ b/hilt/hilt-app-shared-test/src/main/java/org/fnives/test/showcase/hilt/test/shared/ui/NetworkSynchronizedActivityTest.kt @@ -0,0 +1,42 @@ +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 + +@Suppress("UnnecessaryAbstractClass") +abstract 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..aa7ce34 --- /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") +abstract 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..c340211 --- /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") +abstract 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..3446238 --- /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") +abstract 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..ec296b0 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/AuthComposeInstrumentedTest.kt @@ -0,0 +1,195 @@ +package org.fnives.test.showcase.hilt.ui.compose + +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +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.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.ui.compose.idle.ComposeNetworkSyncHelper +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.rules.RuleChain +import org.junit.runner.RunWith +import javax.inject.Inject + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AuthComposeInstrumentedTest { + + 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 + + @Inject + lateinit var composeNetworkSyncHelper: ComposeNetworkSyncHelper + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Rule + @JvmField + val ruleOrder: RuleChain = RuleChain.outerRule(DismissSystemDialogsRule()) + .around(mockServerScenarioSetupTestRule) + .around(dispatcherTestRule) + .around(composeTestRule) + .around(ScreenshotRule("test-showcase-compose")) + + @Before + fun setup() { + TestUserDataLocalStorageModule.replacement = FakeUserDataLocalStorage() + hiltRule.inject() + + stateRestorationTester.setContent { + AppNavigation() + } + robot = ComposeLoginRobot(composeTestRule) + navigationRobot = ComposeNavigationRobot(composeTestRule) + composeNetworkSyncHelper.setup(composeTestRule) + } + + @After + fun tearDown() { + composeNetworkSyncHelper.tearDown() + } + + /** 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 + + navigationRobot.assertHomeScreen() + } + + @Test + fun emptyPasswordShowsProperErrorMessage() { + composeTestRule.mainClock.advanceTimeBy(SPLASH_DELAY) + navigationRobot.assertAuthScreen() + + robot.setUsername("banan") + .assertUsername("banan") + .clickOnLogin() + + 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() + + 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 + + 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 + + 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 + } +} 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..a653854 --- /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.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +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( + semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider, +) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider { + + 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/compose/idle/ComposeIdlingDisposable.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeIdlingDisposable.kt new file mode 100644 index 0000000..e0e0936 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeIdlingDisposable.kt @@ -0,0 +1,22 @@ +package org.fnives.test.showcase.hilt.ui.compose.idle + +import androidx.compose.ui.test.IdlingResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import org.fnives.test.showcase.android.testutil.synchronization.idlingresources.Disposable + +class ComposeIdlingDisposable( + private val idlingResource: IdlingResource, + private val testRule: ComposeTestRule, +) : Disposable { + override var isDisposed: Boolean = false + private set + + init { + testRule.registerIdlingResource(idlingResource) + } + + override fun dispose() { + isDisposed = true + testRule.unregisterIdlingResource(idlingResource) + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt new file mode 100644 index 0000000..a44e1ef --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/ComposeNetworkSyncHelper.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.hilt.ui.compose.idle + +import android.util.Log +import androidx.compose.ui.test.junit4.ComposeTestRule +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.hilt.network.testutil.NetworkSynchronization +import javax.inject.Inject + +class ComposeNetworkSyncHelper @Inject constructor( + private val networkSynchronization: NetworkSynchronization, +) { + + private var disposable: Disposable? = null + + fun setup(composeTestRule: ComposeTestRule) { + disposable = networkSynchronization.networkIdlingResources() + .map(::EspressoToComposeIdlingResourceAdapter) + .map { ComposeIdlingDisposable(it, composeTestRule) } + .let(::CompositeDisposable) + } + + fun tearDown() { + if (disposable == null) { + Log.w("ComposeNetworkSyncHelper", "tearDown called, but setup wasn't!") + } + disposable?.dispose() + } +} diff --git a/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt new file mode 100644 index 0000000..a76b2c4 --- /dev/null +++ b/hilt/hilt-app/src/androidTest/java/org/fnives/test/showcase/hilt/ui/compose/idle/EspressoToComposeIdlingResourceAdapter.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.hilt.ui.compose.idle + +import androidx.test.espresso.IdlingResource + +class EspressoToComposeIdlingResourceAdapter(private val idlingResource: IdlingResource) : androidx.compose.ui.test.IdlingResource { + override val isIdleNow: Boolean get() = idlingResource.isIdleNow +} 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..55921d0 --- /dev/null +++ b/hilt/hilt-network-di-test-util/build.gradle @@ -0,0 +1,44 @@ +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" + implementation project(":test-util-android") +} \ 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..d531f81 --- /dev/null +++ b/hilt/hilt-network-di-test-util/src/main/java/org/fnives/test/showcase/hilt/network/testutil/NetworkSynchronization.kt @@ -0,0 +1,38 @@ +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.android.testutil.synchronization.idlingresources.OkHttp3IdlingResource +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/.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' diff --git a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt index 9a48319..258067b 100644 --- a/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt +++ b/test-util-android/src/main/java/org/fnives/test/showcase/android/testutil/synchronization/idlingresources/OkHttp3IdlingResource.kt @@ -17,29 +17,33 @@ class OkHttp3IdlingResource private constructor( ) : IdlingResource { @Volatile var callback: IdlingResource.ResourceCallback? = null + @Volatile private var isIdleCallbackWasCalled: Boolean = true + private val idleSync = Any() init { val currentCallback = dispatcher.idleCallback dispatcher.idleCallback = Runnable { - sleepForDispatcherDefaultCallInRetrofitErrorState() - callback?.onTransitionToIdle() - currentCallback?.run() - isIdleCallbackWasCalled = true + synchronized(idleSync) { + 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() + override fun isIdleNow(): Boolean = + synchronized(idleSync) { + val isIdle = dispatcher.runningCallsCount() == 0 + if (!isIdle) { + isIdleCallbackWasCalled = false + } + + return@synchronized isIdle && isIdleCallbackWasCalled } - return isIdle - } override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { this.callback = callback