Merge pull request #2 from fknives/hilt-example

Hilt example
This commit is contained in:
Gergely Hegedis 2021-09-19 02:30:29 +03:00 committed by GitHub
commit 961a04af57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
90 changed files with 1996 additions and 131 deletions

View file

@ -2,6 +2,8 @@ plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
// hilt specific
id 'dagger.hilt.android.plugin'
}
android {
@ -25,6 +27,18 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
flavorDimensions 'di'
productFlavors {
hilt {
dimension 'di'
applicationId "org.fnives.test.showcase.hilt"
testInstrumentationRunner "org.fnives.test.showcase.testutils.configuration.HiltTestRunner"
}
koin {
dimension 'di'
applicationId "org.fnives.test.showcase.koin"
}
}
buildFeatures {
viewBinding true
@ -34,10 +48,26 @@ android {
androidTest {
java.srcDirs += "src/sharedTest/java"
}
androidTestHilt {
java.srcDirs += "src/sharedTestHilt/java"
}
androidTestKoin {
java.srcDirs += "src/sharedTestKoin/java"
}
test {
java.srcDirs += "src/sharedTest/java"
java.srcDirs += "src/robolectricTest/java"
}
testHilt {
java.srcDirs += "src/sharedTestHilt/java"
java.srcDirs += "src/robolectricTestHilt/java"
resources.srcDirs += "src/robolectricTestHilt/resources"
}
testKoin {
java.srcDirs += "src/sharedTestKoin/java"
java.srcDirs += "src/robolectricTestKoin/java"
}
}
// needed for androidTest
@ -49,10 +79,17 @@ android {
}
}
hilt {
enableAggregatingTask = true
enableExperimentalClasspathAggregation = 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')
testKoinDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
testKoinReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
testHiltDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
testHiltReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
}
dependencies {
@ -66,7 +103,12 @@ dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version"
// Koin
implementation "io.insert-koin:koin-android:$koin_version"
koinImplementation "io.insert-koin:koin-android:$koin_version"
// Hilt
implementation "com.google.dagger:hilt-android:$hilt_version"
kaptHilt "com.google.dagger:hilt-compiler:$hilt_version"
hiltImplementation "androidx.activity:activity-ktx:$activity_ktx_version"
implementation "androidx.room:room-runtime:$androidx_room_version"
kapt "androidx.room:room-compiler:$androidx_room_version"
@ -98,6 +140,8 @@ dependencies {
testImplementation "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version"
testImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
testImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptTest "com.google.dagger:hilt-compiler:$hilt_version"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
androidTestImplementation "io.insert-koin:koin-test-junit4:$koin_version"
@ -111,4 +155,7 @@ dependencies {
androidTestImplementation "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version"
androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version"
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptAndroidTest "com.google.dagger:hilt-compiler:$hilt_version"
androidTestImplementation project(":network") // hilt needs it
}

View file

@ -0,0 +1,14 @@
package org.fnives.test.showcase.testutils.configuration
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
object AndroidTestServerTypeConfiguration : ServerTypeConfiguration {
override val useHttps: Boolean get() = true
override val url: String get() = "${MockServerScenarioSetup.HTTPS_BASE_URL}:${MockServerScenarioSetup.PORT}/"
override fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) {
val handshakeCertificates = mockServerScenarioSetup.clientCertificates ?: return
HttpsConfigurationModule.handshakeCertificates = handshakeCertificates
}
}

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.testutils.configuration
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)
}

View file

@ -0,0 +1,33 @@
package org.fnives.test.showcase.testutils.configuration
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import okhttp3.tls.HandshakeCertificates
import org.fnives.test.showcase.hilt.SessionLessQualifier
import org.fnives.test.showcase.network.di.hilt.BindsBaseOkHttpClient
import org.fnives.test.showcase.network.di.hilt.HiltNetworkModule
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [BindsBaseOkHttpClient::class]
)
object HttpsConfigurationModule {
lateinit var handshakeCertificates: HandshakeCertificates
@Provides
@Singleton
@SessionLessQualifier
fun bindsBaseOkHttpClient(enableLogging: Boolean) =
HiltNetworkModule.provideSessionLessOkHttpClient(enableLogging)
.newBuilder()
.sslSocketFactory(
handshakeCertificates.sslSocketFactory(),
handshakeCertificates.trustManager
)
.build()
}

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.fnives.test.showcase">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<activity
android:name=".ui.splash.HiltSplashActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ui.home.HiltMainActivity" />
<activity android:name=".ui.auth.HiltAuthActivity" />
</application>
</manifest>

View file

@ -0,0 +1,7 @@
package org.fnives.test.showcase
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TestShowcaseApplication : Application()

View file

@ -0,0 +1,52 @@
package org.fnives.test.showcase.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.core.session.SessionExpirationListener
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.session.SessionExpirationListenerImpl
import org.fnives.test.showcase.storage.SharedPreferencesManagerImpl
import org.fnives.test.showcase.storage.database.DatabaseInitialization
import org.fnives.test.showcase.storage.favourite.FavouriteContentLocalStorageImpl
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object AppModule {
@Provides
fun provideBaseUrl(): String = BaseUrlProvider.get().baseUrl
@Provides
fun enableLogging(): Boolean = true
@Singleton
@Provides
fun provideFavouriteDao(@ApplicationContext context: Context) =
DatabaseInitialization.create(context).favouriteDao
@Provides
fun provideSharedPreferencesManagerImpl(@ApplicationContext context: Context) =
SharedPreferencesManagerImpl.create(context)
@Singleton
@Provides
fun provideUserDataLocalStorage(
sharedPreferencesManagerImpl: SharedPreferencesManagerImpl
): UserDataLocalStorage = sharedPreferencesManagerImpl
@Provides
fun provideFavouriteContentLocalStorage(
favouriteContentLocalStorageImpl: FavouriteContentLocalStorageImpl
): FavouriteContentLocalStorage = favouriteContentLocalStorageImpl
@Provides
internal fun bindSessionExpirationListener(
sessionExpirationListenerImpl: SessionExpirationListenerImpl
): SessionExpirationListener = sessionExpirationListenerImpl
}

View file

@ -0,0 +1,14 @@
package org.fnives.test.showcase.ui
import android.content.Context
import org.fnives.test.showcase.ui.auth.HiltAuthActivity
import org.fnives.test.showcase.ui.home.HiltMainActivity
object IntentCoordinator {
fun mainActivitygetStartIntent(context: Context) =
HiltMainActivity.getStartIntent(context)
fun authActivitygetStartIntent(context: Context) =
HiltAuthActivity.getStartIntent(context)
}

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.ui
import androidx.activity.ComponentActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStoreOwner
import androidx.activity.viewModels as androidxViewModel
inline fun <reified T : ViewModel> ViewModelStoreOwner.viewModels(): Lazy<T> =
when (this) {
is ComponentActivity -> androidxViewModel()
else -> throw IllegalStateException("Only supports activity viewModel for now")
}

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.ui.auth
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class HiltAuthActivity : AuthActivity() {
companion object {
fun getStartIntent(context: Context): Intent = Intent(context, HiltAuthActivity::class.java)
}
}

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.ui.home
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class HiltMainActivity : MainActivity() {
companion object {
fun getStartIntent(context: Context): Intent = Intent(context, HiltMainActivity::class.java)
}
}

View file

@ -0,0 +1,6 @@
package org.fnives.test.showcase.ui.splash
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class HiltSplashActivity : SplashActivity()

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.fnives.test.showcase">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<activity
android:name=".ui.splash.SplashActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ui.home.MainActivity" />
<activity android:name=".ui.auth.AuthActivity" />
</application>
</manifest>

View file

@ -1,6 +1,6 @@
package org.fnives.test.showcase.di
import org.fnives.test.showcase.core.di.createCoreModule
import org.fnives.test.showcase.core.di.koin.createCoreModule
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.session.SessionExpirationListenerImpl
import org.fnives.test.showcase.storage.LocalDatabase

View file

@ -0,0 +1,14 @@
package org.fnives.test.showcase.ui
import android.content.Context
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.home.MainActivity
object IntentCoordinator {
fun mainActivitygetStartIntent(context: Context) =
MainActivity.getStartIntent(context)
fun authActivitygetStartIntent(context: Context) =
AuthActivity.getStartIntent(context)
}

View file

@ -0,0 +1,8 @@
package org.fnives.test.showcase.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStoreOwner
import org.koin.androidx.viewmodel.ext.android.viewModel
inline fun <reified T : ViewModel> ViewModelStoreOwner.viewModels(): Lazy<T> =
viewModel()

View file

@ -6,24 +6,13 @@
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".TestShowcaseApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:name=".TestShowcaseApplication"
android:theme="@style/Theme.TestShowCase"
tools:ignore="AllowBackup">
<activity android:name=".ui.splash.SplashActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ui.home.MainActivity"/>
<activity android:name=".ui.auth.AuthActivity"/>
</application>
tools:ignore="AllowBackup"/>
</manifest>

View file

@ -4,15 +4,20 @@ 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.core.session.SessionExpirationListener
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.IntentCoordinator
import javax.inject.Inject
class SessionExpirationListenerImpl(private val context: Context) : SessionExpirationListener {
class SessionExpirationListenerImpl @Inject constructor(
@ApplicationContext
private val context: Context
) : SessionExpirationListener {
override fun onSessionExpired() {
Handler(Looper.getMainLooper()).post {
context.startActivity(
AuthActivity.getStartIntent(context)
IntentCoordinator.authActivitygetStartIntent(context)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)

View file

@ -7,13 +7,20 @@ import org.fnives.test.showcase.model.session.Session
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class SharedPreferencesManagerImpl(private val sharedPreferences: SharedPreferences) : UserDataLocalStorage {
class SharedPreferencesManagerImpl(
private val sharedPreferences: SharedPreferences
) : UserDataLocalStorage {
override var session: Session? by SessionDelegate(SESSION_KEY)
private class SessionDelegate(private val key: String) : ReadWriteProperty<SharedPreferencesManagerImpl, Session?> {
private class SessionDelegate(private val key: String) :
ReadWriteProperty<SharedPreferencesManagerImpl, Session?> {
override fun setValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>, value: Session?) {
override fun setValue(
thisRef: SharedPreferencesManagerImpl,
property: KProperty<*>,
value: Session?
) {
if (value == null) {
thisRef.sharedPreferences.edit().remove(key).apply()
} else {
@ -25,7 +32,10 @@ class SharedPreferencesManagerImpl(private val sharedPreferences: SharedPreferen
}
}
override fun getValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>): Session? {
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

View file

@ -4,8 +4,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.fnives.test.showcase.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 {
class FavouriteContentLocalStorageImpl(private val favouriteDao: FavouriteDao) : FavouriteContentLocalStorage {
override fun observeFavourites(): Flow<List<ContentId>> =
favouriteDao.get().map { it.map(FavouriteEntity::contentId).map(::ContentId) }

View file

@ -9,12 +9,12 @@ import androidx.core.widget.doAfterTextChanged
import com.google.android.material.snackbar.Snackbar
import org.fnives.test.showcase.R
import org.fnives.test.showcase.databinding.ActivityAuthenticationBinding
import org.fnives.test.showcase.ui.home.MainActivity
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.fnives.test.showcase.ui.IntentCoordinator
import org.fnives.test.showcase.ui.viewModels
class AuthActivity : AppCompatActivity() {
open class AuthActivity : AppCompatActivity() {
private val viewModel by viewModel<AuthViewModel>()
private val viewModel by viewModels<AuthViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -35,7 +35,7 @@ class AuthActivity : AppCompatActivity() {
}
viewModel.navigateToHome.observe(this) {
it.consume() ?: return@observe
startActivity(MainActivity.getStartIntent(this))
startActivity(IntentCoordinator.mainActivitygetStartIntent(this))
finishAffinity()
}
setContentView(binding.root)

View file

@ -4,14 +4,17 @@ 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.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
import org.fnives.test.showcase.ui.shared.Event
import javax.inject.Inject
class AuthViewModel(private val loginUseCase: LoginUseCase) : ViewModel() {
@HiltViewModel
class AuthViewModel @Inject constructor(private val loginUseCase: LoginUseCase) : ViewModel() {
private val _username = MutableLiveData<String>()
val username: LiveData<String> = _username

View file

@ -9,14 +9,14 @@ import androidx.recyclerview.widget.LinearLayoutManager
import org.fnives.test.showcase.R
import org.fnives.test.showcase.databinding.ActivityMainBinding
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.IntentCoordinator
import org.fnives.test.showcase.ui.shared.VerticalSpaceItemDecoration
import org.fnives.test.showcase.ui.shared.getThemePrimaryColor
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.fnives.test.showcase.ui.viewModels
class MainActivity : AppCompatActivity() {
open class MainActivity : AppCompatActivity() {
private val viewModel by viewModel<MainViewModel>()
private val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -45,7 +45,7 @@ class MainActivity : AppCompatActivity() {
}
viewModel.navigateToAuth.observe(this) {
it.consume() ?: return@observe
startActivity(AuthActivity.getStartIntent(this))
startActivity(IntentCoordinator.authActivitygetStartIntent(this))
finishAffinity()
}
viewModel.loading.observe(this) {

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase
@ -16,8 +17,10 @@ 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 org.fnives.test.showcase.ui.shared.Event
import javax.inject.Inject
class MainViewModel(
@HiltViewModel
class MainViewModel @Inject constructor(
private val getAllContentUseCase: GetAllContentUseCase,
private val logoutUseCase: LogoutUseCase,
private val fetchContentUseCase: FetchContentUseCase,

View file

@ -3,21 +3,20 @@ package org.fnives.test.showcase.ui.splash
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.fnives.test.showcase.R
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.home.MainActivity
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.fnives.test.showcase.ui.IntentCoordinator
import org.fnives.test.showcase.ui.viewModels
class SplashActivity : AppCompatActivity() {
open class SplashActivity : AppCompatActivity() {
private val viewModel by viewModel<SplashViewModel>()
private val viewModel by viewModels<SplashViewModel>()
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 -> MainActivity.getStartIntent(this)
SplashViewModel.NavigateTo.AUTHENTICATION -> AuthActivity.getStartIntent(this)
SplashViewModel.NavigateTo.HOME -> IntentCoordinator.mainActivitygetStartIntent(this)
SplashViewModel.NavigateTo.AUTHENTICATION -> IntentCoordinator.authActivitygetStartIntent(this)
null -> return@observe
}
startActivity(intent)

View file

@ -4,12 +4,15 @@ 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.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.ui.shared.Event
import javax.inject.Inject
class SplashViewModel(isUserLoggedInUseCase: IsUserLoggedInUseCase) : ViewModel() {
@HiltViewModel
class SplashViewModel @Inject constructor(isUserLoggedInUseCase: IsUserLoggedInUseCase) : ViewModel() {
private val _navigateTo = MutableLiveData<Event<NavigateTo>>()
val navigateTo: LiveData<Event<NavigateTo>> = _navigateTo

View file

@ -0,0 +1,97 @@
package org.fnives.test.showcase.favourite
import androidx.test.ext.junit.runners.AndroidJUnit4
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.runBlocking
import kotlinx.coroutines.test.TestCoroutineDispatcher
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.storage.database.DatabaseInitialization
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.inject.Inject
@Suppress("TestFunctionName")
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
internal class FavouriteContentLocalStorageImplTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var sut: FavouriteContentLocalStorage
private lateinit var testDispatcher: TestCoroutineDispatcher
@Before
fun setUp() {
testDispatcher = TestCoroutineDispatcher()
DatabaseInitialization.dispatcher = testDispatcher
hiltRule.inject()
}
@Test
fun GIVEN_content_id_WHEN_added_to_Favourite_THEN_it_can_be_read_out() = runBlocking {
val expected = listOf(ContentId("a"))
sut.markAsFavourite(ContentId("a"))
val actual = sut.observeFavourites().first()
Assert.assertEquals(expected, actual)
}
@Test
fun GIVEN_content_id_added_WHEN_removed_to_Favourite_THEN_it_no_longer_can_be_read_out() =
runBlocking {
val expected = listOf<ContentId>()
sut.markAsFavourite(ContentId("b"))
sut.deleteAsFavourite(ContentId("b"))
val actual = sut.observeFavourites().first()
Assert.assertEquals(expected, actual)
}
@Test
fun GIVEN_empty_database_WHILE_observing_content_WHEN_favourite_added_THEN_change_is_emitted() =
runBlocking<Unit> {
val expected = listOf(listOf(), listOf(ContentId("a")))
val testDispatcher = TestCoroutineDispatcher()
val actual = async(testDispatcher) {
sut.observeFavourites().take(2).toList()
}
testDispatcher.advanceUntilIdle()
sut.markAsFavourite(ContentId("a"))
Assert.assertEquals(expected, actual.await())
}
@Test
fun GIVEN_non_empty_database_WHILE_observing_content_WHEN_favourite_removed_THEN_change_is_emitted() =
runBlocking<Unit> {
val expected = listOf(listOf(ContentId("a")), listOf())
sut.markAsFavourite(ContentId("a"))
val testDispatcher = TestCoroutineDispatcher()
val actual = async(testDispatcher) {
sut.observeFavourites().take(2).toList()
}
testDispatcher.advanceUntilIdle()
sut.deleteAsFavourite(ContentId("a"))
Assert.assertEquals(expected, actual.await())
}
}

View file

@ -0,0 +1,4 @@
sdk=28
shadows=org.fnives.test.showcase.testutils.shadow.ShadowSnackbar
instrumentedPackages=androidx.loader.content
application=dagger.hilt.android.testing.HiltTestApplication

View file

@ -1,31 +1,31 @@
package org.fnives.test.showcase.testutils.statesetup
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.koin.test.KoinTest
import org.koin.test.get
object SetupLoggedInState : KoinTest {
private val logoutUseCase get() = get<LogoutUseCase>()
private val loginUseCase get() = get<LoginUseCase>()
private val isUserLoggedInUseCase get() = get<IsUserLoggedInUseCase>()
fun setupLogin(mockServerScenarioSetup: MockServerScenarioSetup) {
mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b"))
runBlocking {
loginUseCase.invoke(LoginCredentials("a", "b"))
}
}
fun isLoggedIn() = isUserLoggedInUseCase.invoke()
fun setupLogout() {
runBlocking { logoutUseCase.invoke() }
}
}
// package org.fnives.test.showcase.testutils.statesetup
//
// import kotlinx.coroutines.runBlocking
// import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
// import org.fnives.test.showcase.core.login.LoginUseCase
// import org.fnives.test.showcase.core.login.LogoutUseCase
// import org.fnives.test.showcase.model.auth.LoginCredentials
// import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
// import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
// import org.koin.test.KoinTest
// import org.koin.test.get
//
// object SetupLoggedInState : KoinTest {
//
// private val logoutUseCase get() = get<LogoutUseCase>()
// private val loginUseCase get() = get<LoginUseCase>()
// private val isUserLoggedInUseCase get() = get<IsUserLoggedInUseCase>()
//
// fun setupLogin(mockServerScenarioSetup: MockServerScenarioSetup) {
// mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b"))
// runBlocking {
// loginUseCase.invoke(LoginCredentials("a", "b"))
// }
// }
//
// fun isLoggedIn() = isUserLoggedInUseCase.invoke()
//
// fun setupLogout() {
// runBlocking { logoutUseCase.invoke() }
// }
// }

View file

@ -21,14 +21,14 @@ import org.fnives.test.showcase.testutils.robot.Robot
import org.fnives.test.showcase.testutils.viewactions.PullToRefresh
import org.fnives.test.showcase.testutils.viewactions.WithDrawable
import org.fnives.test.showcase.testutils.viewactions.notIntended
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.ActivityClassHolder
import org.hamcrest.Matchers.allOf
class HomeRobot : Robot {
override fun init() {
Intents.init()
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
Intents.intending(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
.respondWith(Instrumentation.ActivityResult(0, null))
}
@ -37,11 +37,11 @@ class HomeRobot : Robot {
}
fun assertNavigatedToAuth() = apply {
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
Intents.intended(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
}
fun assertDidNotNavigateToAuth() = apply {
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
notIntended(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
}
fun clickSignOut() = apply {

View file

@ -21,7 +21,7 @@ import org.fnives.test.showcase.testutils.configuration.SpecificTestConfiguratio
import org.fnives.test.showcase.testutils.configuration.TestConfigurationsFactory
import org.fnives.test.showcase.testutils.robot.Robot
import org.fnives.test.showcase.testutils.viewactions.notIntended
import org.fnives.test.showcase.ui.home.MainActivity
import org.fnives.test.showcase.ui.ActivityClassHolder
import org.hamcrest.core.IsNot.not
class LoginRobot(
@ -37,7 +37,7 @@ class LoginRobot(
override fun init() {
Intents.init()
intending(hasComponent(MainActivity::class.java.canonicalName))
intending(hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
}
@ -91,10 +91,10 @@ class LoginRobot(
}
fun assertNavigatedToHome() = apply {
intended(hasComponent(MainActivity::class.java.canonicalName))
intended(hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
}
fun assertNotNavigatedToHome() = apply {
notIntended(hasComponent(MainActivity::class.java.canonicalName))
notIntended(hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
}
}

View file

@ -5,16 +5,15 @@ import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import org.fnives.test.showcase.testutils.robot.Robot
import org.fnives.test.showcase.testutils.viewactions.notIntended
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.home.MainActivity
import org.fnives.test.showcase.ui.ActivityClassHolder
class SplashRobot : Robot {
override fun init() {
Intents.init()
Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
Intents.intending(IntentMatchers.hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
.respondWith(Instrumentation.ActivityResult(0, null))
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
Intents.intending(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
.respondWith(Instrumentation.ActivityResult(0, null))
}
@ -23,18 +22,18 @@ class SplashRobot : Robot {
}
fun assertHomeIsStarted() = apply {
Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
Intents.intended(IntentMatchers.hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
}
fun assertHomeIsNotStarted() = apply {
notIntended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName))
notIntended(IntentMatchers.hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
}
fun assertAuthIsStarted() = apply {
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
Intents.intended(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
}
fun assertAuthIsNotStarted() = apply {
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName))
notIntended(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
}
}

View file

@ -0,0 +1,42 @@
package org.fnives.test.showcase.testutils.idling
import androidx.annotation.CheckResult
import androidx.test.espresso.IdlingResource
import com.jakewharton.espresso.OkHttp3IdlingResource
import okhttp3.OkHttpClient
import org.fnives.test.showcase.hilt.SessionLessQualifier
import org.fnives.test.showcase.hilt.SessionQualifier
import javax.inject.Inject
class NetworkSynchronization @Inject constructor(
@SessionQualifier
private val sessionOkhttpClient: OkHttpClient,
@SessionLessQualifier
private val sessionlessOkhttpClient: OkHttpClient
) {
@CheckResult
fun registerNetworkingSynchronization(): Disposable {
val idlingResources = OkHttpClientTypes.values()
.map { it to getOkHttpClient(it) }
.associateBy { it.second.dispatcher }
.values
.map { (key, client) -> client.asIdlingResource(key.qualifier) }
.map(::IdlingResourceDisposable)
return CompositeDisposable(idlingResources)
}
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")
}
}

View file

@ -0,0 +1,30 @@
package org.fnives.test.showcase.testutils.statesetup
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import javax.inject.Inject
class SetupLoggedInState @Inject constructor(
private val logoutUseCase: LogoutUseCase,
private val loginUseCase: LoginUseCase,
private val isUserLoggedInUseCase: IsUserLoggedInUseCase
) {
fun setupLogin(mockServerScenarioSetup: MockServerScenarioSetup) {
mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b"))
runBlocking {
loginUseCase.invoke(LoginCredentials("a", "b"))
}
}
fun isLoggedIn() = isUserLoggedInUseCase.invoke()
fun setupLogout() {
runBlocking { logoutUseCase.invoke() }
}
}

View file

@ -0,0 +1,14 @@
package org.fnives.test.showcase.ui
import org.fnives.test.showcase.ui.auth.HiltAuthActivity
import org.fnives.test.showcase.ui.home.HiltMainActivity
import org.fnives.test.showcase.ui.splash.HiltSplashActivity
object ActivityClassHolder {
fun authActivity() = HiltAuthActivity::class
fun mainActivity() = HiltMainActivity::class
fun splashActivity() = HiltSplashActivity::class
}

View file

@ -0,0 +1,248 @@
package org.fnives.test.showcase.ui.home
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.fnives.test.showcase.model.content.FavouriteContent
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.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
import org.fnives.test.showcase.testutils.idling.Disposable
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
import org.fnives.test.showcase.testutils.idling.loopMainThreadFor
import org.fnives.test.showcase.testutils.idling.loopMainThreadUntilIdleWithIdlingResources
import org.fnives.test.showcase.testutils.robot.RobotTestRule
import org.fnives.test.showcase.testutils.statesetup.SetupLoggedInState
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 javax.inject.Inject
@Suppress("TestFunctionName")
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class MainActivityTest {
private lateinit var activityScenario: ActivityScenario<HiltMainActivity>
@Rule
@JvmField
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Rule
@JvmField
val snackbarVerificationTestRule = SpecificTestConfigurationsFactory.createSnackbarVerification()
@Rule
@JvmField
val robotRule = RobotTestRule(HomeRobot())
private val homeRobot get() = robotRule.robot
@Rule
@JvmField
val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
@Rule
@JvmField
val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule()
@Rule
@JvmField
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var setupLoggedInState: SetupLoggedInState
@Inject
lateinit var networkSynchronization: NetworkSynchronization
private lateinit var disposable: Disposable
@Before
fun setUp() {
SpecificTestConfigurationsFactory.createServerTypeConfiguration()
.invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
hiltRule.inject()
setupLoggedInState.setupLogin(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
disposable = networkSynchronization.registerNetworkingSynchronization()
}
@After
fun tearDown() {
activityScenario.moveToState(Lifecycle.State.DESTROYED)
disposable.dispose()
}
@Test
fun GIVEN_initialized_MainActivity_WHEN_signout_is_clicked_THEN_user_is_signed_out() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(ContentScenario.Error(false))
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickSignOut()
mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed()
homeRobot.assertNavigatedToAuth()
Assert.assertEquals(false, setupLoggedInState.isLoggedIn())
}
@Test
fun GIVEN_success_response_WHEN_data_is_returned_THEN_it_is_shown_on_the_ui() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(ContentScenario.Success(false))
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
ContentData.contentSuccess.forEach {
homeRobot.assertContainsItem(FavouriteContent(it, false))
}
homeRobot.assertDidNotNavigateToAuth()
}
@Test
fun GIVEN_success_response_WHEN_item_is_clicked_THEN_ui_is_updated() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(ContentScenario.Success(false))
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickOnContentItem(ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
homeRobot.assertContainsItem(expectedItem)
.assertDidNotNavigateToAuth()
}
@Test
fun GIVEN_success_response_WHEN_item_is_clicked_THEN_ui_is_updated_even_if_activity_is_recreated() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(ContentScenario.Success(false))
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickOnContentItem(ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), true)
activityScenario.moveToState(Lifecycle.State.DESTROYED)
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.assertContainsItem(expectedItem)
.assertDidNotNavigateToAuth()
}
@Test
fun GIVEN_success_response_WHEN_item_is_clicked_then_clicked_again_THEN_ui_is_updated() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(ContentScenario.Success(false))
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickOnContentItem(ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.clickOnContentItem(ContentData.contentSuccess.first())
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
val expectedItem = FavouriteContent(ContentData.contentSuccess.first(), false)
homeRobot.assertContainsItem(expectedItem)
.assertDidNotNavigateToAuth()
}
@Test
fun GIVEN_error_response_WHEN_loaded_THEN_error_is_Shown() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(ContentScenario.Error(false))
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.assertContainsNoItems()
.assertContainsError()
.assertDidNotNavigateToAuth()
}
@Test
fun GIVEN_error_response_then_success_WHEN_retried_THEN_success_is_shown() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(
ContentScenario.Error(false)
.then(ContentScenario.Success(false))
)
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.swipeRefresh()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loopMainThreadFor(2000L)
ContentData.contentSuccess.forEach {
homeRobot.assertContainsItem(FavouriteContent(it, false))
}
homeRobot.assertDidNotNavigateToAuth()
}
@Test
fun GIVEN_success_then_error_WHEN_retried_THEN_error_is_shown() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(
ContentScenario.Success(false)
.then(ContentScenario.Error(false))
)
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.swipeRefresh()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loopMainThreadUntilIdleWithIdlingResources()
mainDispatcherTestRule.advanceTimeBy(1000L)
loopMainThreadFor(1000)
homeRobot
.assertContainsError()
.assertContainsNoItems()
.assertDidNotNavigateToAuth()
}
@Test
fun GIVEN_unauthenticated_then_success_WHEN_loaded_THEN_success_is_shown() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(
ContentScenario.Unauthorized(false)
.then(ContentScenario.Success(true))
)
.setScenario(RefreshTokenScenario.Success)
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
ContentData.contentSuccess.forEach {
homeRobot.assertContainsItem(FavouriteContent(it, false))
}
homeRobot.assertDidNotNavigateToAuth()
}
@Test
fun GIVEN_unauthenticated_then_error_WHEN_loaded_THEN_navigated_to_auth() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup
.setScenario(ContentScenario.Unauthorized(false))
.setScenario(RefreshTokenScenario.Error)
activityScenario = ActivityScenario.launch(HiltMainActivity::class.java)
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
homeRobot.assertNavigatedToAuth()
Assert.assertEquals(false, setupLoggedInState.isLoggedIn())
}
}

View file

@ -0,0 +1,165 @@
package org.fnives.test.showcase.ui.login
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.fnives.test.showcase.R
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
import org.fnives.test.showcase.testutils.idling.Disposable
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
import org.fnives.test.showcase.testutils.robot.RobotTestRule
import org.fnives.test.showcase.ui.auth.HiltAuthActivity
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
@Suppress("TestFunctionName")
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AuthActivityTest {
private lateinit var activityScenario: ActivityScenario<HiltAuthActivity>
@Rule
@JvmField
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Rule
@JvmField
val snackbarVerificationTestRule = SpecificTestConfigurationsFactory.createSnackbarVerification()
@Rule
@JvmField
val robotRule = RobotTestRule(LoginRobot())
private val loginRobot get() = robotRule.robot
@Rule
@JvmField
val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
@Rule
@JvmField
val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule()
@Rule
@JvmField
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var networkSynchronization: NetworkSynchronization
private lateinit var disposable: Disposable
@Before
fun setUp() {
SpecificTestConfigurationsFactory.createServerTypeConfiguration()
.invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
hiltRule.inject()
disposable = networkSynchronization.registerNetworkingSynchronization()
}
@After
fun tearDown() {
activityScenario.moveToState(Lifecycle.State.DESTROYED)
disposable.dispose()
}
@Test
fun GIVEN_non_empty_password_and_username_and_successful_response_WHEN_signIn_THEN_no_error_is_shown_and_navigating_to_home() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup.setScenario(
AuthScenario.Success(
password = "alma",
username = "banan"
)
)
activityScenario = ActivityScenario.launch(HiltAuthActivity::class.java)
loginRobot
.setPassword("alma")
.setUsername("banan")
.assertPassword("alma")
.assertUsername("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleOrActivityIsDestroyed()
loginRobot.assertNavigatedToHome()
}
@Test
fun GIVEN_empty_password_and_username_WHEN_signIn_THEN_error_password_is_shown() {
activityScenario = ActivityScenario.launch(HiltAuthActivity::class.java)
loginRobot
.setUsername("banan")
.assertUsername("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loginRobot.assertErrorIsShown(R.string.password_is_invalid)
.assertNotNavigatedToHome()
.assertNotLoading()
}
@Test
fun GIVEN_password_and_empty_username_WHEN_signIn_THEN_error_username_is_shown() {
activityScenario = ActivityScenario.launch(HiltAuthActivity::class.java)
loginRobot
.setPassword("banan")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loginRobot.assertErrorIsShown(R.string.username_is_invalid)
.assertNotNavigatedToHome()
.assertNotLoading()
}
@Test
fun GIVEN_password_and_username_and_invalid_credentials_response_WHEN_signIn_THEN_error_invalid_credentials_is_shown() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup.setScenario(
AuthScenario.InvalidCredentials(username = "alma", password = "banan")
)
activityScenario = ActivityScenario.launch(HiltAuthActivity::class.java)
loginRobot
.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loginRobot.assertErrorIsShown(R.string.credentials_invalid)
.assertNotNavigatedToHome()
.assertNotLoading()
}
@Test
fun GIVEN_password_and_username_and_error_response_WHEN_signIn_THEN_error_invalid_credentials_is_shown() {
mockServerScenarioSetupTestRule.mockServerScenarioSetup.setScenario(
AuthScenario.GenericError(username = "alma", password = "banan")
)
activityScenario = ActivityScenario.launch(HiltAuthActivity::class.java)
loginRobot
.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
.clickOnLogin()
.assertLoadingBeforeRequests()
mainDispatcherTestRule.advanceUntilIdleWithIdlingResources()
loginRobot.assertErrorIsShown(R.string.something_went_wrong)
.assertNotNavigatedToHome()
.assertNotLoading()
}
}

View file

@ -0,0 +1,99 @@
package org.fnives.test.showcase.ui.splash
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule
import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory
import org.fnives.test.showcase.testutils.idling.Disposable
import org.fnives.test.showcase.testutils.idling.NetworkSynchronization
import org.fnives.test.showcase.testutils.robot.RobotTestRule
import org.fnives.test.showcase.testutils.statesetup.SetupLoggedInState
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.test.KoinTest
import javax.inject.Inject
@Suppress("TestFunctionName")
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class SplashActivityTest : KoinTest {
private var activityScenario: ActivityScenario<HiltSplashActivity>? = null
private val splashRobot: SplashRobot get() = robotTestRule.robot
@Rule
@JvmField
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Rule
@JvmField
val robotTestRule = RobotTestRule(SplashRobot())
@Rule
@JvmField
val mainDispatcherTestRule = SpecificTestConfigurationsFactory.createMainDispatcherTestRule()
@Rule
@JvmField
val mockServerScenarioSetupTestRule = MockServerScenarioSetupTestRule()
@Rule
@JvmField
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var setupLoggedInState: SetupLoggedInState
@Inject
lateinit var networkSynchronization: NetworkSynchronization
var disposable: Disposable? = null
@Before
fun setUp() {
SpecificTestConfigurationsFactory.createServerTypeConfiguration()
.invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
hiltRule.inject()
disposable = networkSynchronization.registerNetworkingSynchronization()
}
@After
fun tearDown() {
activityScenario?.moveToState(Lifecycle.State.DESTROYED)
disposable?.dispose()
}
@Test
fun GIVEN_loggedInState_WHEN_opened_THEN_MainActivity_is_started() {
setupLoggedInState.setupLogin(mockServerScenarioSetupTestRule.mockServerScenarioSetup)
activityScenario = ActivityScenario.launch(HiltSplashActivity::class.java)
mainDispatcherTestRule.advanceTimeBy(500)
splashRobot.assertHomeIsStarted()
.assertAuthIsNotStarted()
setupLoggedInState.setupLogout()
}
@Test
fun GIVEN_loggedOffState_WHEN_opened_THEN_AuthActivity_is_started() {
setupLoggedInState.setupLogout()
activityScenario = ActivityScenario.launch(HiltSplashActivity::class.java)
mainDispatcherTestRule.advanceTimeBy(500)
splashRobot.assertAuthIsStarted()
.assertHomeIsNotStarted()
}
}

View file

@ -17,7 +17,8 @@ class ReloadKoinModulesIfNecessaryTestRule : TestRule {
object : Statement() {
override fun evaluate() {
if (GlobalContext.getOrNull() == null) {
val application = ApplicationProvider.getApplicationContext<TestShowcaseApplication>()
val application =
ApplicationProvider.getApplicationContext<TestShowcaseApplication>()
startKoin {
androidContext(application)
modules(createAppModules(BaseUrlProvider.get()))

View file

@ -0,0 +1,31 @@
package org.fnives.test.showcase.testutils.statesetup
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.koin.test.KoinTest
import org.koin.test.get
object SetupLoggedInState : KoinTest {
private val logoutUseCase get() = get<LogoutUseCase>()
private val loginUseCase get() = get<LoginUseCase>()
private val isUserLoggedInUseCase get() = get<IsUserLoggedInUseCase>()
fun setupLogin(mockServerScenarioSetup: MockServerScenarioSetup) {
mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b"))
runBlocking {
loginUseCase.invoke(LoginCredentials("a", "b"))
}
}
fun isLoggedIn() = isUserLoggedInUseCase.invoke()
fun setupLogout() {
runBlocking { logoutUseCase.invoke() }
}
}

View file

@ -0,0 +1,14 @@
package org.fnives.test.showcase.ui
import org.fnives.test.showcase.ui.auth.AuthActivity
import org.fnives.test.showcase.ui.home.MainActivity
import org.fnives.test.showcase.ui.splash.SplashActivity
object ActivityClassHolder {
fun authActivity() = AuthActivity::class
fun mainActivity() = MainActivity::class
fun splashActivity() = SplashActivity::class
}

View file

@ -2,12 +2,14 @@
buildscript {
ext.kotlin_version = "1.5.30"
ext.detekt_version = "1.18.1"
ext.hilt_version = "2.38.1"
repositories {
mavenCentral()
google()
maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
classpath 'com.android.tools.build:gradle:7.0.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.0"
@ -22,6 +24,15 @@ allprojects {
repositories {
mavenCentral()
google()
maven {
url "https://maven.pkg.github.com/fknives/ReloadableHiltModule"
credentials {
username = project.findProperty("GITHUB_USERNAME") ?: System.getenv("GITHUB_USERNAME")
password = project.findProperty("GITHUB_TOKEN") ?: System.getenv("GITHUB_TOKEN")
}
// how to get token
// https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token
}
}
}
@ -29,12 +40,12 @@ task clean(type: Delete) {
delete rootProject.buildDir
}
task unitTests(dependsOn: ["app:testDebugUnitTest", "core:test", "network:test"]){
task unitTests(dependsOn: ["app:testKoinDebugUnitTest", "app:testHiltDebugUnitTest", "core:test", "network:test"]){
group = 'Tests'
description = 'Run all unit tests'
}
task androidTests(dependsOn: "app:connectedAndroidTest"){
task androidTests(dependsOn: ["app:connectedKoinDebugAndroidTest", "app:connectedHiltDebugAndroidTest"]){
group = 'Tests'
description = 'Run all Android tests'
}

View file

@ -1,6 +1,7 @@
plugins {
id 'java-library'
id 'kotlin'
id 'kotlin-kapt'
}
java {
@ -14,15 +15,27 @@ compileKotlin {
}
}
kapt {
correctErrorTypes = true
}
dependencies {
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
api project(":model")
implementation project(":network")
// hilt
implementation "com.google.dagger:hilt-core:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
implementation "org.fnives.library.reloadable.module:annotation:$reloadable_module_version"
kapt "org.fnives.library.reloadable.module:annotation-processor:$reloadable_module_version"
testImplementation "io.insert-koin:koin-test-junit5:$koin_version"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version"
testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
kaptTest "com.google.dagger:dagger-compiler:$hilt_version"
testImplementation "com.squareup.retrofit2:retrofit:$retrofit_version"
}

View file

@ -2,8 +2,9 @@ package org.fnives.test.showcase.core.content
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.ContentId
import javax.inject.Inject
class AddContentToFavouriteUseCase internal constructor(
class AddContentToFavouriteUseCase @Inject internal constructor(
private val favouriteContentLocalStorage: FavouriteContentLocalStorage
) {

View file

@ -7,6 +7,7 @@ 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.core.di.hilt.LoggedInModuleInject
import org.fnives.test.showcase.core.shared.Optional
import org.fnives.test.showcase.core.shared.mapIntoResource
import org.fnives.test.showcase.core.shared.wrapIntoAnswer
@ -14,7 +15,9 @@ import org.fnives.test.showcase.model.content.Content
import org.fnives.test.showcase.model.shared.Resource
import org.fnives.test.showcase.network.content.ContentRemoteSource
internal class ContentRepository(private val contentRemoteSource: ContentRemoteSource) {
internal class ContentRepository @LoggedInModuleInject constructor(
private val contentRemoteSource: ContentRemoteSource
) {
private val mutableContentFlow = MutableStateFlow(Optional<List<Content>>(null))
private val requestFlow: Flow<Resource<List<Content>>> = flow {

View file

@ -1,6 +1,8 @@
package org.fnives.test.showcase.core.content
class FetchContentUseCase internal constructor(private val contentRepository: ContentRepository) {
import javax.inject.Inject
class FetchContentUseCase @Inject internal constructor(private val contentRepository: ContentRepository) {
fun invoke() = contentRepository.fetch()
}

View file

@ -7,8 +7,9 @@ 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 internal constructor(
class GetAllContentUseCase @Inject internal constructor(
private val contentRepository: ContentRepository,
private val favouriteContentLocalStorage: FavouriteContentLocalStorage
) {

View file

@ -2,8 +2,9 @@ package org.fnives.test.showcase.core.content
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.ContentId
import javax.inject.Inject
class RemoveContentFromFavouritesUseCase internal constructor(
class RemoveContentFromFavouritesUseCase @Inject internal constructor(
private val favouriteContentLocalStorage: FavouriteContentLocalStorage
) {

View file

@ -0,0 +1,33 @@
package org.fnives.test.showcase.core.di.hilt
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.core.session.SessionExpirationAdapter
import org.fnives.test.showcase.core.storage.NetworkSessionLocalStorageAdapter
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener
import org.fnives.test.showcase.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)
}

View file

@ -0,0 +1,8 @@
package org.fnives.test.showcase.core.di.hilt
import org.fnives.library.reloadable.module.annotation.ReloadableModule
@ReloadableModule
@Target(AnnotationTarget.CONSTRUCTOR)
@Retention(AnnotationRetention.SOURCE)
annotation class LoggedInModuleInject

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.core.di
package org.fnives.test.showcase.core.di.koin
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase
import org.fnives.test.showcase.core.content.ContentRepository
@ -14,7 +14,7 @@ import org.fnives.test.showcase.core.storage.NetworkSessionLocalStorageAdapter
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.network.di.createNetworkModules
import org.fnives.test.showcase.network.di.koin.createNetworkModules
import org.koin.core.module.Module
import org.koin.core.scope.Scope
import org.koin.dsl.module
@ -42,7 +42,7 @@ fun repositoryModule() = module {
fun useCaseModule() = module {
factory { LoginUseCase(get(), get()) }
factory { LogoutUseCase(get()) }
factory { LogoutUseCase(get(), null) }
factory { GetAllContentUseCase(get(), get()) }
factory { AddContentToFavouriteUseCase(get()) }
factory { RemoveContentFromFavouritesUseCase(get()) }

View file

@ -1,8 +1,11 @@
package org.fnives.test.showcase.core.login
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import javax.inject.Inject
class IsUserLoggedInUseCase(private val userDataLocalStorage: UserDataLocalStorage) {
class IsUserLoggedInUseCase @Inject constructor(
private val userDataLocalStorage: UserDataLocalStorage
) {
fun invoke(): Boolean = userDataLocalStorage.session != null
}

View file

@ -7,8 +7,9 @@ import org.fnives.test.showcase.model.auth.LoginStatus
import org.fnives.test.showcase.model.shared.Answer
import org.fnives.test.showcase.network.auth.LoginRemoteSource
import org.fnives.test.showcase.network.auth.model.LoginStatusResponses
import javax.inject.Inject
class LoginUseCase internal constructor(
class LoginUseCase @Inject internal constructor(
private val loginRemoteSource: LoginRemoteSource,
private val userDataLocalStorage: UserDataLocalStorage
) {

View file

@ -1,13 +1,22 @@
package org.fnives.test.showcase.core.login
import org.fnives.test.showcase.core.di.repositoryModule
import org.fnives.test.showcase.core.di.hilt.ReloadLoggedInModuleInjectModule
import org.fnives.test.showcase.core.di.koin.repositoryModule
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.koin.core.context.loadKoinModules
import org.koin.mp.KoinPlatformTools
class LogoutUseCase(private val storage: UserDataLocalStorage) {
class LogoutUseCase(
private val storage: UserDataLocalStorage,
private val reloadLoggedInModuleInjectModule: ReloadLoggedInModuleInjectModule?
) {
suspend fun invoke() {
loadKoinModules(repositoryModule())
if (KoinPlatformTools.defaultContext().getOrNull() == null) {
reloadLoggedInModuleInjectModule?.reload()
} else {
loadKoinModules(repositoryModule())
}
storage.session = null
}
}

View file

@ -1,11 +1,11 @@
package org.fnives.test.showcase.core.session
import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener
import javax.inject.Inject
internal class SessionExpirationAdapter(
internal class SessionExpirationAdapter @Inject constructor(
private val sessionExpirationListener: SessionExpirationListener
) :
NetworkSessionExpirationListener {
) : NetworkSessionExpirationListener {
override fun onSessionExpired() = sessionExpirationListener.onSessionExpired()
}

View file

@ -2,8 +2,9 @@ package org.fnives.test.showcase.core.storage
import org.fnives.test.showcase.model.session.Session
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import javax.inject.Inject
internal class NetworkSessionLocalStorageAdapter(
internal class NetworkSessionLocalStorageAdapter @Inject constructor(
private val userDataLocalStorage: UserDataLocalStorage
) : NetworkSessionLocalStorage {

View file

@ -0,0 +1,56 @@
package org.fnives.test.showcase.core.login.hilt
import kotlinx.coroutines.test.runBlockingTest
import org.fnives.test.showcase.core.content.ContentRepository
import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
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.verifyNoMoreInteractions
import org.mockito.kotlin.verifyZeroInteractions
import javax.inject.Inject
@Suppress("TestFunctionName")
internal class LogoutUseCaseTest {
@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)
}
@Test
fun WHEN_no_call_THEN_storage_is_not_interacted() {
verifyZeroInteractions(mockUserDataLocalStorage)
}
@Test
fun WHEN_logout_invoked_THEN_storage_is_cleared() = runBlockingTest {
val repositoryBefore = contentRepository
sut.invoke()
testCoreComponent.inject(this@LogoutUseCaseTest)
val repositoryAfter = contentRepository
verify(mockUserDataLocalStorage, times(1)).session = null
verifyNoMoreInteractions(mockUserDataLocalStorage)
Assertions.assertNotSame(repositoryBefore, repositoryAfter)
}
}

View file

@ -0,0 +1,36 @@
package org.fnives.test.showcase.core.login.hilt
import dagger.BindsInstance
import dagger.Component
import org.fnives.test.showcase.core.di.hilt.CoreModule
import org.fnives.test.showcase.core.di.hilt.ReloadLoggedInModuleInjectModuleImpl
import org.fnives.test.showcase.core.session.SessionExpirationListener
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.network.di.hilt.BindsBaseOkHttpClient
import org.fnives.test.showcase.network.di.hilt.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)
}

View file

@ -1,8 +1,9 @@
package org.fnives.test.showcase.core.login
package org.fnives.test.showcase.core.login.koin
import kotlinx.coroutines.test.runBlockingTest
import org.fnives.test.showcase.core.content.ContentRepository
import org.fnives.test.showcase.core.di.createCoreModule
import org.fnives.test.showcase.core.di.koin.createCoreModule
import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.model.network.BaseUrl
import org.junit.jupiter.api.AfterEach
@ -27,7 +28,7 @@ internal class LogoutUseCaseTest : KoinTest {
@BeforeEach
fun setUp() {
mockUserDataLocalStorage = mock()
sut = LogoutUseCase(mockUserDataLocalStorage)
sut = LogoutUseCase(mockUserDataLocalStorage, null)
startKoin {
modules(
createCoreModule(

View file

@ -6,6 +6,7 @@ project.ext {
androidx_livedata_version = "2.3.1"
androidx_swiperefreshlayout_version = "1.1.0"
androidx_room_version = "2.3.0"
activity_ktx_version = "1.3.1"
coroutines_version = "1.4.3"
koin_version = "3.1.2"
@ -13,6 +14,7 @@ project.ext {
retrofit_version = "2.9.0"
okhttp_version = "4.9.1"
moshi_version = "1.12.0"
reloadable_module_version = "0.1.0"
testing_androidx_code_version = "1.4.0"
testing_androidx_junit_version = "1.1.3"

View file

@ -6,4 +6,8 @@ plugins {
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
dependencies {
implementation "javax.inject:javax.inject:1"
}

View file

@ -0,0 +1,6 @@
package org.fnives.test.showcase.hilt
import javax.inject.Qualifier
@Qualifier
annotation class SessionLessQualifier

View file

@ -0,0 +1,6 @@
package org.fnives.test.showcase.hilt
import javax.inject.Qualifier
@Qualifier
annotation class SessionQualifier

View file

@ -17,8 +17,13 @@ dependencies {
implementation "com.squareup.moshi:moshi:$moshi_version"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
// koin
api "io.insert-koin:koin-core:$koin_version"
// hilt
implementation "com.google.dagger:hilt-core:$hilt_version"
api project(":model")
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
@ -28,4 +33,6 @@ dependencies {
testImplementation "io.insert-koin:koin-test-junit5:$koin_version"
testImplementation "org.skyscreamer:jsonassert:$testing_json_assert_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
kaptTest "com.google.dagger:dagger-compiler:$hilt_version"
}

View file

@ -7,8 +7,9 @@ import org.fnives.test.showcase.network.shared.ExceptionWrapper
import org.fnives.test.showcase.network.shared.exceptions.ParsingException
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject
internal class LoginErrorConverter {
internal class LoginErrorConverter @Inject constructor() {
@Throws(ParsingException::class)
suspend fun invoke(request: suspend () -> Response<LoginResponse>): LoginStatusResponses =

View file

@ -7,8 +7,9 @@ import org.fnives.test.showcase.network.auth.model.LoginStatusResponses
import org.fnives.test.showcase.network.shared.ExceptionWrapper
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.shared.exceptions.ParsingException
import javax.inject.Inject
internal class LoginRemoteSourceImpl constructor(
internal class LoginRemoteSourceImpl @Inject constructor(
private val loginService: LoginService,
private val loginErrorConverter: LoginErrorConverter
) : LoginRemoteSource {

View file

@ -4,8 +4,11 @@ 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.network.shared.ExceptionWrapper
import javax.inject.Inject
internal class ContentRemoteSourceImpl(private val contentService: ContentService) : ContentRemoteSource {
internal class ContentRemoteSourceImpl @Inject constructor(
private val contentService: ContentService
) : ContentRemoteSource {
override suspend fun get(): List<Content> =
ExceptionWrapper.wrap {

View file

@ -0,0 +1,17 @@
package org.fnives.test.showcase.network.di.hilt
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import org.fnives.test.showcase.hilt.SessionLessQualifier
@InstallIn(SingletonComponent::class)
@Module
abstract class BindsBaseOkHttpClient {
@Binds
@SessionLessQualifier
abstract fun bindsSessionLess(okHttpClient: OkHttpClient): OkHttpClient
}

View file

@ -0,0 +1,96 @@
package org.fnives.test.showcase.network.di.hilt
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import org.fnives.test.showcase.hilt.SessionLessQualifier
import org.fnives.test.showcase.hilt.SessionQualifier
import org.fnives.test.showcase.network.auth.LoginRemoteSource
import org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl
import org.fnives.test.showcase.network.auth.LoginService
import org.fnives.test.showcase.network.content.ContentRemoteSource
import org.fnives.test.showcase.network.content.ContentRemoteSourceImpl
import org.fnives.test.showcase.network.content.ContentService
import org.fnives.test.showcase.network.di.setupLogging
import org.fnives.test.showcase.network.session.AuthenticationHeaderInterceptor
import org.fnives.test.showcase.network.session.AuthenticationHeaderUtils
import org.fnives.test.showcase.network.session.SessionAuthenticator
import org.fnives.test.showcase.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) =
OkHttpClient.Builder()
.addInterceptor(PlatformInterceptor())
.setupLogging(enableLogging)
.build()
@Provides
@Singleton
@SessionLessQualifier
fun provideSessionLessRetrofit(
baseUrl: String,
converterFactory: Converter.Factory,
@SessionLessQualifier okHttpClient: OkHttpClient
) = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(converterFactory)
.client(okHttpClient)
.build()
@Provides
@Singleton
@SessionQualifier
internal fun provideSessionOkHttpClient(
@SessionLessQualifier okHttpClient: OkHttpClient,
sessionAuthenticator: SessionAuthenticator,
authenticationHeaderUtils: AuthenticationHeaderUtils
) =
okHttpClient
.newBuilder()
.authenticator(sessionAuthenticator)
.addInterceptor(AuthenticationHeaderInterceptor(authenticationHeaderUtils))
.build()
@Provides
@Singleton
@SessionQualifier
fun provideSessionRetrofit(
@SessionLessQualifier retrofit: Retrofit,
@SessionQualifier okHttpClient: OkHttpClient
) = 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)
}

View file

@ -1,4 +1,4 @@
package org.fnives.test.showcase.network.di
package org.fnives.test.showcase.network.di.koin
import okhttp3.OkHttpClient
import org.fnives.test.showcase.model.network.BaseUrl
@ -9,6 +9,7 @@ import org.fnives.test.showcase.network.auth.LoginService
import org.fnives.test.showcase.network.content.ContentRemoteSource
import org.fnives.test.showcase.network.content.ContentRemoteSourceImpl
import org.fnives.test.showcase.network.content.ContentService
import org.fnives.test.showcase.network.di.setupLogging
import org.fnives.test.showcase.network.session.AuthenticationHeaderInterceptor
import org.fnives.test.showcase.network.session.AuthenticationHeaderUtils
import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener

View file

@ -1,14 +1,18 @@
package org.fnives.test.showcase.network.session
import okhttp3.Request
import javax.inject.Inject
internal class AuthenticationHeaderUtils(private val networkSessionLocalStorage: NetworkSessionLocalStorage) {
internal class AuthenticationHeaderUtils @Inject 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()
okhttpRequest.newBuilder()
.header(KEY, networkSessionLocalStorage.session?.accessToken.orEmpty()).build()
companion object {
private const val KEY = "Authorization"

View file

@ -6,8 +6,9 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl
import javax.inject.Inject
internal class SessionAuthenticator(
internal class SessionAuthenticator @Inject constructor(
private val networkSessionLocalStorage: NetworkSessionLocalStorage,
private val loginRemoteSource: LoginRemoteSourceImpl,
private val authenticationHeaderUtils: AuthenticationHeaderUtils,

View file

@ -0,0 +1,44 @@
package org.fnives.test.showcase.network
import dagger.BindsInstance
import dagger.Component
import org.fnives.test.showcase.network.auth.hilt.LoginRemoteSourceRefreshActionImplTest
import org.fnives.test.showcase.network.auth.hilt.LoginRemoteSourceTest
import org.fnives.test.showcase.network.content.hilt.ContentRemoteSourceImplTest
import org.fnives.test.showcase.network.content.hilt.SessionExpirationTest
import org.fnives.test.showcase.network.di.hilt.BindsBaseOkHttpClient
import org.fnives.test.showcase.network.di.hilt.HiltNetworkModule
import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener
import org.fnives.test.showcase.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)
}

View file

@ -0,0 +1,98 @@
package org.fnives.test.showcase.network.auth.hilt
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.network.DaggerTestNetworkComponent
import org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.shared.exceptions.ParsingException
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
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)
}
@Test
fun GIVEN_successful_response_WHEN_refresh_request_is_fired_THEN_session() = runBlocking {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success)
val expected = ContentData.refreshSuccessResponse
val actual = sut.refresh(ContentData.refreshSuccessResponse.refreshToken)
Assertions.assertEquals(expected, actual)
}
@Test
fun GIVEN_successful_response_WHEN_refresh_request_is_fired_THEN_the_request_is_setup_properly() =
runBlocking {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, 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())
}
@Test
fun GIVEN_internal_error_response_WHEN_refresh_request_is_fired_THEN_network_exception_is_thrown() {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error)
Assertions.assertThrows(NetworkException::class.java) {
runBlocking { sut.refresh(ContentData.refreshSuccessResponse.refreshToken) }
}
}
@Test
fun GIVEN_invalid_json_response_WHEN_refresh_request_is_fired_THEN_network_exception_is_thrown() {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.UnexpectedJsonAsSuccessResponse)
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) }
}
}
@Test
fun GIVEN_malformed_json_response_WHEN_refresh_request_is_fired_THEN_network_exception_is_thrown() {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.MalformedJson)
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) }
}
}
}

View file

@ -0,0 +1,120 @@
package org.fnives.test.showcase.network.auth.hilt
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.network.DaggerTestNetworkComponent
import org.fnives.test.showcase.network.auth.LoginRemoteSource
import org.fnives.test.showcase.network.auth.model.LoginStatusResponses
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.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.shared.exceptions.ParsingException
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 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<NetworkSessionLocalStorage>()
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("a", "b"))
val expected = LoginStatusResponses.Success(ContentData.loginSuccessResponse)
val actual = sut.login(LoginCredentials("a", "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("a", "b"), false)
sut.login(LoginCredentials("a", "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("a", "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("a", "b"))
val expected = LoginStatusResponses.InvalidCredentials
val actual = sut.login(LoginCredentials("a", "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("a", "b"))
Assertions.assertThrows(NetworkException::class.java) {
runBlocking { sut.login(LoginCredentials("a", "b")) }
}
}
@DisplayName("GIVEN invalid json response WHEN request is fired THEN network exception is thrown")
@Test
fun invalidJsonMeansParsingException() {
mockServerScenarioSetup.setScenario(AuthScenario.UnexpectedJsonAsSuccessResponse("a", "b"))
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.login(LoginCredentials("a", "b")) }
}
}
@DisplayName("GIVEN malformed json response WHEN request is fired THEN network exception is thrown")
@Test
fun malformedJsonMeansParsingException() {
mockServerScenarioSetup.setScenario(AuthScenario.MalformedJsonAsSuccessResponse("a", "b"))
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.login(LoginCredentials("a", "b")) }
}
}
}

View file

@ -1,8 +1,9 @@
package org.fnives.test.showcase.network.auth
package org.fnives.test.showcase.network.auth.koin
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.network.di.createNetworkModules
import org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl
import org.fnives.test.showcase.network.di.koin.createNetworkModules
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage

View file

@ -1,10 +1,11 @@
package org.fnives.test.showcase.network.auth
package org.fnives.test.showcase.network.auth.koin
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.network.auth.LoginRemoteSource
import org.fnives.test.showcase.network.auth.model.LoginStatusResponses
import org.fnives.test.showcase.network.di.createNetworkModules
import org.fnives.test.showcase.network.di.koin.createNetworkModules
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

View file

@ -3,7 +3,7 @@ package org.fnives.test.showcase.network.content
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockWebServer
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.network.di.createNetworkModules
import org.fnives.test.showcase.network.di.koin.createNetworkModules
import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.junit.jupiter.api.AfterEach

View file

@ -0,0 +1,114 @@
package org.fnives.test.showcase.network.content.hilt
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.network.DaggerTestNetworkComponent
import org.fnives.test.showcase.network.content.ContentRemoteSourceImpl
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.shared.exceptions.ParsingException
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
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 {
@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)
}
@Test
fun GIVEN_successful_response_WHEN_getting_content_THEN_its_parsed_and_returned_correctly() = runBlocking {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.Success(false))
val expected = ContentData.contentSuccess
val actual = sut.get()
Assertions.assertEquals(expected, actual)
}
@Test
fun GIVEN_successful_response_WHEN_getting_content_THEN_the_request_is_setup_properly() = runBlocking {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.Success(false), 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())
}
@Test
fun GIVEN_response_with_missing_Field_WHEN_getting_content_THEN_invalid_is_ignored_others_are_returned() = runBlocking {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.SuccessWithMissingFields(false))
val expected = ContentData.contentSuccessWithMissingFields
val actual = sut.get()
Assertions.assertEquals(expected, actual)
}
@Test
fun GIVEN_error_response_WHEN_getting_content_THEN_network_request_is_thrown() {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.Error(false))
Assertions.assertThrows(NetworkException::class.java) {
runBlocking { sut.get() }
}
}
@Test
fun GIVEN_unexpected_json_response_WHEN_getting_content_THEN_parsing_request_is_thrown() {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.UnexpectedJsonAsSuccessResponse(false))
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.get() }
}
}
@Test
fun GIVEN_malformed_json_response_WHEN_getting_content_THEN_parsing_request_is_thrown() {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.MalformedJsonAsSuccessResponse(false))
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.get() }
}
}
}

View file

@ -0,0 +1,110 @@
package org.fnives.test.showcase.network.content.hilt
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.session.Session
import org.fnives.test.showcase.network.DaggerTestNetworkComponent
import org.fnives.test.showcase.network.content.ContentRemoteSourceImpl
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.fnives.test.showcase.network.session.NetworkSessionExpirationListener
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.network.shared.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.shared.exceptions.NetworkException
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.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.verifyNoMoreInteractions
import org.mockito.kotlin.verifyZeroInteractions
import org.mockito.kotlin.whenever
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(false)
.then(ContentScenario.Success(true)),
false
)
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, 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")
)
verify(mockNetworkSessionLocalStorage, times(1)).session =
ContentData.refreshSuccessResponse
verifyZeroInteractions(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(false))
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error)
Assertions.assertThrows(NetworkException::class.java) {
runBlocking { sut.get() }
}
verify(mockNetworkSessionLocalStorage, times(3)).session
verify(mockNetworkSessionLocalStorage, times(1)).session = null
verifyNoMoreInteractions(mockNetworkSessionLocalStorage)
verify(mockNetworkSessionExpirationListener, times(1)).onSessionExpired()
}
}

View file

@ -1,8 +1,9 @@
package org.fnives.test.showcase.network.content
package org.fnives.test.showcase.network.content.koin
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.network.di.createNetworkModules
import org.fnives.test.showcase.network.content.ContentRemoteSourceImpl
import org.fnives.test.showcase.network.di.koin.createNetworkModules
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage

View file

@ -1,9 +1,10 @@
package org.fnives.test.showcase.network.content
package org.fnives.test.showcase.network.content.koin
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.model.session.Session
import org.fnives.test.showcase.network.di.createNetworkModules
import org.fnives.test.showcase.network.content.ContentRemoteSourceImpl
import org.fnives.test.showcase.network.di.koin.createNetworkModules
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