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 'com.android.application'
id 'kotlin-android' id 'kotlin-android'
id 'kotlin-kapt' id 'kotlin-kapt'
// hilt specific
id 'dagger.hilt.android.plugin'
} }
android { android {
@ -25,6 +27,18 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 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 { buildFeatures {
viewBinding true viewBinding true
@ -34,10 +48,26 @@ android {
androidTest { androidTest {
java.srcDirs += "src/sharedTest/java" java.srcDirs += "src/sharedTest/java"
} }
androidTestHilt {
java.srcDirs += "src/sharedTestHilt/java"
}
androidTestKoin {
java.srcDirs += "src/sharedTestKoin/java"
}
test { test {
java.srcDirs += "src/sharedTest/java" java.srcDirs += "src/sharedTest/java"
java.srcDirs += "src/robolectricTest/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 // needed for androidTest
@ -49,10 +79,17 @@ android {
} }
} }
hilt {
enableAggregatingTask = true
enableExperimentalClasspathAggregation = true
}
afterEvaluate { afterEvaluate {
// making sure the :mockserver is assembled after :clean when running tests // making sure the :mockserver is assembled after :clean when running tests
testDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble') testKoinDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
testReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble') testKoinReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
testHiltDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
testHiltReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble')
} }
dependencies { dependencies {
@ -66,7 +103,12 @@ dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version" implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version"
// Koin // 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" implementation "androidx.room:room-runtime:$androidx_room_version"
kapt "androidx.room:room-compiler:$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 "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version"
testImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version" testImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_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 "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
androidTestImplementation "io.insert-koin:koin-test-junit4:$koin_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 "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version"
androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version" androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version"
androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_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 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.model.network.BaseUrl
import org.fnives.test.showcase.session.SessionExpirationListenerImpl import org.fnives.test.showcase.session.SessionExpirationListenerImpl
import org.fnives.test.showcase.storage.LocalDatabase 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" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".TestShowcaseApplication"
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:name=".TestShowcaseApplication"
android:theme="@style/Theme.TestShowCase" android:theme="@style/Theme.TestShowCase"
tools:ignore="AllowBackup"> 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>
</manifest> </manifest>

View file

@ -4,15 +4,20 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext
import org.fnives.test.showcase.core.session.SessionExpirationListener 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() { override fun onSessionExpired() {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
context.startActivity( context.startActivity(
AuthActivity.getStartIntent(context) IntentCoordinator.authActivitygetStartIntent(context)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_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.properties.ReadWriteProperty
import kotlin.reflect.KProperty 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) 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) { if (value == null) {
thisRef.sharedPreferences.edit().remove(key).apply() thisRef.sharedPreferences.edit().remove(key).apply()
} else { } 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 values = thisRef.sharedPreferences.getStringSet(key, null)?.toList()
val accessToken = values?.firstOrNull { it.startsWith(ACCESS_TOKEN_KEY) } val accessToken = values?.firstOrNull { it.startsWith(ACCESS_TOKEN_KEY) }
?.drop(ACCESS_TOKEN_KEY.length) ?: return null ?.drop(ACCESS_TOKEN_KEY.length) ?: return null

View file

@ -4,8 +4,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.ContentId 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>> = override fun observeFavourites(): Flow<List<ContentId>> =
favouriteDao.get().map { it.map(FavouriteEntity::contentId).map(::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 com.google.android.material.snackbar.Snackbar
import org.fnives.test.showcase.R import org.fnives.test.showcase.R
import org.fnives.test.showcase.databinding.ActivityAuthenticationBinding import org.fnives.test.showcase.databinding.ActivityAuthenticationBinding
import org.fnives.test.showcase.ui.home.MainActivity import org.fnives.test.showcase.ui.IntentCoordinator
import org.koin.androidx.viewmodel.ext.android.viewModel 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -35,7 +35,7 @@ class AuthActivity : AppCompatActivity() {
} }
viewModel.navigateToHome.observe(this) { viewModel.navigateToHome.observe(this) {
it.consume() ?: return@observe it.consume() ?: return@observe
startActivity(MainActivity.getStartIntent(this)) startActivity(IntentCoordinator.mainActivitygetStartIntent(this))
finishAffinity() finishAffinity()
} }
setContentView(binding.root) setContentView(binding.root)

View file

@ -4,14 +4,17 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fnives.test.showcase.core.login.LoginUseCase import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.model.auth.LoginCredentials import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.auth.LoginStatus import org.fnives.test.showcase.model.auth.LoginStatus
import org.fnives.test.showcase.model.shared.Answer import org.fnives.test.showcase.model.shared.Answer
import org.fnives.test.showcase.ui.shared.Event 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>() private val _username = MutableLiveData<String>()
val username: LiveData<String> = _username 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.R
import org.fnives.test.showcase.databinding.ActivityMainBinding import org.fnives.test.showcase.databinding.ActivityMainBinding
import org.fnives.test.showcase.model.content.ContentId 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.VerticalSpaceItemDecoration
import org.fnives.test.showcase.ui.shared.getThemePrimaryColor 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -45,7 +45,7 @@ class MainActivity : AppCompatActivity() {
} }
viewModel.navigateToAuth.observe(this) { viewModel.navigateToAuth.observe(this) {
it.consume() ?: return@observe it.consume() ?: return@observe
startActivity(AuthActivity.getStartIntent(this)) startActivity(IntentCoordinator.authActivitygetStartIntent(this))
finishAffinity() finishAffinity()
} }
viewModel.loading.observe(this) { viewModel.loading.observe(this) {

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase 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.content.FavouriteContent
import org.fnives.test.showcase.model.shared.Resource import org.fnives.test.showcase.model.shared.Resource
import org.fnives.test.showcase.ui.shared.Event 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 getAllContentUseCase: GetAllContentUseCase,
private val logoutUseCase: LogoutUseCase, private val logoutUseCase: LogoutUseCase,
private val fetchContentUseCase: FetchContentUseCase, private val fetchContentUseCase: FetchContentUseCase,

View file

@ -3,21 +3,20 @@ package org.fnives.test.showcase.ui.splash
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import org.fnives.test.showcase.R import org.fnives.test.showcase.R
import org.fnives.test.showcase.ui.auth.AuthActivity import org.fnives.test.showcase.ui.IntentCoordinator
import org.fnives.test.showcase.ui.home.MainActivity import org.fnives.test.showcase.ui.viewModels
import org.koin.androidx.viewmodel.ext.android.viewModel
class SplashActivity : AppCompatActivity() { open class SplashActivity : AppCompatActivity() {
private val viewModel by viewModel<SplashViewModel>() private val viewModel by viewModels<SplashViewModel>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash) setContentView(R.layout.activity_splash)
viewModel.navigateTo.observe(this) { viewModel.navigateTo.observe(this) {
val intent = when (it.consume()) { val intent = when (it.consume()) {
SplashViewModel.NavigateTo.HOME -> MainActivity.getStartIntent(this) SplashViewModel.NavigateTo.HOME -> IntentCoordinator.mainActivitygetStartIntent(this)
SplashViewModel.NavigateTo.AUTHENTICATION -> AuthActivity.getStartIntent(this) SplashViewModel.NavigateTo.AUTHENTICATION -> IntentCoordinator.authActivitygetStartIntent(this)
null -> return@observe null -> return@observe
} }
startActivity(intent) startActivity(intent)

View file

@ -4,12 +4,15 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.ui.shared.Event 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>>() private val _navigateTo = MutableLiveData<Event<NavigateTo>>()
val navigateTo: LiveData<Event<NavigateTo>> = _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 // package org.fnives.test.showcase.testutils.statesetup
//
import kotlinx.coroutines.runBlocking // import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase // import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase
import org.fnives.test.showcase.core.login.LoginUseCase // import org.fnives.test.showcase.core.login.LoginUseCase
import org.fnives.test.showcase.core.login.LogoutUseCase // import org.fnives.test.showcase.core.login.LogoutUseCase
import org.fnives.test.showcase.model.auth.LoginCredentials // import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup // import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario // import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.koin.test.KoinTest // import org.koin.test.KoinTest
import org.koin.test.get // import org.koin.test.get
//
object SetupLoggedInState : KoinTest { // object SetupLoggedInState : KoinTest {
//
private val logoutUseCase get() = get<LogoutUseCase>() // private val logoutUseCase get() = get<LogoutUseCase>()
private val loginUseCase get() = get<LoginUseCase>() // private val loginUseCase get() = get<LoginUseCase>()
private val isUserLoggedInUseCase get() = get<IsUserLoggedInUseCase>() // private val isUserLoggedInUseCase get() = get<IsUserLoggedInUseCase>()
//
fun setupLogin(mockServerScenarioSetup: MockServerScenarioSetup) { // fun setupLogin(mockServerScenarioSetup: MockServerScenarioSetup) {
mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b")) // mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b"))
runBlocking { // runBlocking {
loginUseCase.invoke(LoginCredentials("a", "b")) // loginUseCase.invoke(LoginCredentials("a", "b"))
} // }
} // }
//
fun isLoggedIn() = isUserLoggedInUseCase.invoke() // fun isLoggedIn() = isUserLoggedInUseCase.invoke()
//
fun setupLogout() { // fun setupLogout() {
runBlocking { logoutUseCase.invoke() } // 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.PullToRefresh
import org.fnives.test.showcase.testutils.viewactions.WithDrawable import org.fnives.test.showcase.testutils.viewactions.WithDrawable
import org.fnives.test.showcase.testutils.viewactions.notIntended 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 import org.hamcrest.Matchers.allOf
class HomeRobot : Robot { class HomeRobot : Robot {
override fun init() { override fun init() {
Intents.init() Intents.init()
Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) Intents.intending(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
.respondWith(Instrumentation.ActivityResult(0, null)) .respondWith(Instrumentation.ActivityResult(0, null))
} }
@ -37,11 +37,11 @@ class HomeRobot : Robot {
} }
fun assertNavigatedToAuth() = apply { fun assertNavigatedToAuth() = apply {
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) Intents.intended(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
} }
fun assertDidNotNavigateToAuth() = apply { fun assertDidNotNavigateToAuth() = apply {
notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) notIntended(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
} }
fun clickSignOut() = apply { 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.configuration.TestConfigurationsFactory
import org.fnives.test.showcase.testutils.robot.Robot import org.fnives.test.showcase.testutils.robot.Robot
import org.fnives.test.showcase.testutils.viewactions.notIntended 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 import org.hamcrest.core.IsNot.not
class LoginRobot( class LoginRobot(
@ -37,7 +37,7 @@ class LoginRobot(
override fun init() { override fun init() {
Intents.init() Intents.init()
intending(hasComponent(MainActivity::class.java.canonicalName)) intending(hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()))
} }
@ -91,10 +91,10 @@ class LoginRobot(
} }
fun assertNavigatedToHome() = apply { fun assertNavigatedToHome() = apply {
intended(hasComponent(MainActivity::class.java.canonicalName)) intended(hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
} }
fun assertNotNavigatedToHome() = apply { 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 androidx.test.espresso.intent.matcher.IntentMatchers
import org.fnives.test.showcase.testutils.robot.Robot import org.fnives.test.showcase.testutils.robot.Robot
import org.fnives.test.showcase.testutils.viewactions.notIntended 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.fnives.test.showcase.ui.home.MainActivity
class SplashRobot : Robot { class SplashRobot : Robot {
override fun init() { override fun init() {
Intents.init() Intents.init()
Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) Intents.intending(IntentMatchers.hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
.respondWith(Instrumentation.ActivityResult(0, null)) .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)) .respondWith(Instrumentation.ActivityResult(0, null))
} }
@ -23,18 +22,18 @@ class SplashRobot : Robot {
} }
fun assertHomeIsStarted() = apply { fun assertHomeIsStarted() = apply {
Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) Intents.intended(IntentMatchers.hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
} }
fun assertHomeIsNotStarted() = apply { fun assertHomeIsNotStarted() = apply {
notIntended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) notIntended(IntentMatchers.hasComponent(ActivityClassHolder.mainActivity().java.canonicalName))
} }
fun assertAuthIsStarted() = apply { fun assertAuthIsStarted() = apply {
Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) Intents.intended(IntentMatchers.hasComponent(ActivityClassHolder.authActivity().java.canonicalName))
} }
fun assertAuthIsNotStarted() = apply { 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() { object : Statement() {
override fun evaluate() { override fun evaluate() {
if (GlobalContext.getOrNull() == null) { if (GlobalContext.getOrNull() == null) {
val application = ApplicationProvider.getApplicationContext<TestShowcaseApplication>() val application =
ApplicationProvider.getApplicationContext<TestShowcaseApplication>()
startKoin { startKoin {
androidContext(application) androidContext(application)
modules(createAppModules(BaseUrlProvider.get())) 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 { buildscript {
ext.kotlin_version = "1.5.30" ext.kotlin_version = "1.5.30"
ext.detekt_version = "1.18.1" ext.detekt_version = "1.18.1"
ext.hilt_version = "2.38.1"
repositories { repositories {
mavenCentral() mavenCentral()
google() google()
maven { url "https://plugins.gradle.org/m2/" } maven { url "https://plugins.gradle.org/m2/" }
} }
dependencies { dependencies {
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
classpath 'com.android.tools.build:gradle:7.0.2' classpath 'com.android.tools.build:gradle:7.0.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.0" classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.0"
@ -22,6 +24,15 @@ allprojects {
repositories { repositories {
mavenCentral() mavenCentral()
google() 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 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' group = 'Tests'
description = 'Run all unit tests' description = 'Run all unit tests'
} }
task androidTests(dependsOn: "app:connectedAndroidTest"){ task androidTests(dependsOn: ["app:connectedKoinDebugAndroidTest", "app:connectedHiltDebugAndroidTest"]){
group = 'Tests' group = 'Tests'
description = 'Run all Android tests' description = 'Run all Android tests'
} }

View file

@ -1,6 +1,7 @@
plugins { plugins {
id 'java-library' id 'java-library'
id 'kotlin' id 'kotlin'
id 'kotlin-kapt'
} }
java { java {
@ -14,15 +15,27 @@ compileKotlin {
} }
} }
kapt {
correctErrorTypes = true
}
dependencies { dependencies {
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
api project(":model") api project(":model")
implementation project(":network") 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 "io.insert-koin:koin-test-junit5:$koin_version"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version" testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version"
testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version"
testRuntimeOnly "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.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.ContentId 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 private val favouriteContentLocalStorage: FavouriteContentLocalStorage
) { ) {

View file

@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf 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.Optional
import org.fnives.test.showcase.core.shared.mapIntoResource import org.fnives.test.showcase.core.shared.mapIntoResource
import org.fnives.test.showcase.core.shared.wrapIntoAnswer 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.model.shared.Resource
import org.fnives.test.showcase.network.content.ContentRemoteSource 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 mutableContentFlow = MutableStateFlow(Optional<List<Content>>(null))
private val requestFlow: Flow<Resource<List<Content>>> = flow { private val requestFlow: Flow<Resource<List<Content>>> = flow {

View file

@ -1,6 +1,8 @@
package org.fnives.test.showcase.core.content 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() 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.ContentId
import org.fnives.test.showcase.model.content.FavouriteContent import org.fnives.test.showcase.model.content.FavouriteContent
import org.fnives.test.showcase.model.shared.Resource 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 contentRepository: ContentRepository,
private val favouriteContentLocalStorage: FavouriteContentLocalStorage 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.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.content.ContentId 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 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.AddContentToFavouriteUseCase
import org.fnives.test.showcase.core.content.ContentRepository 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.UserDataLocalStorage
import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage
import org.fnives.test.showcase.model.network.BaseUrl 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.module.Module
import org.koin.core.scope.Scope import org.koin.core.scope.Scope
import org.koin.dsl.module import org.koin.dsl.module
@ -42,7 +42,7 @@ fun repositoryModule() = module {
fun useCaseModule() = module { fun useCaseModule() = module {
factory { LoginUseCase(get(), get()) } factory { LoginUseCase(get(), get()) }
factory { LogoutUseCase(get()) } factory { LogoutUseCase(get(), null) }
factory { GetAllContentUseCase(get(), get()) } factory { GetAllContentUseCase(get(), get()) }
factory { AddContentToFavouriteUseCase(get()) } factory { AddContentToFavouriteUseCase(get()) }
factory { RemoveContentFromFavouritesUseCase(get()) } factory { RemoveContentFromFavouritesUseCase(get()) }

View file

@ -1,8 +1,11 @@
package org.fnives.test.showcase.core.login package org.fnives.test.showcase.core.login
import org.fnives.test.showcase.core.storage.UserDataLocalStorage 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 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.model.shared.Answer
import org.fnives.test.showcase.network.auth.LoginRemoteSource import org.fnives.test.showcase.network.auth.LoginRemoteSource
import org.fnives.test.showcase.network.auth.model.LoginStatusResponses 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 loginRemoteSource: LoginRemoteSource,
private val userDataLocalStorage: UserDataLocalStorage private val userDataLocalStorage: UserDataLocalStorage
) { ) {

View file

@ -1,13 +1,22 @@
package org.fnives.test.showcase.core.login 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.fnives.test.showcase.core.storage.UserDataLocalStorage
import org.koin.core.context.loadKoinModules 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() { suspend fun invoke() {
loadKoinModules(repositoryModule()) if (KoinPlatformTools.defaultContext().getOrNull() == null) {
reloadLoggedInModuleInjectModule?.reload()
} else {
loadKoinModules(repositoryModule())
}
storage.session = null storage.session = null
} }
} }

View file

@ -1,11 +1,11 @@
package org.fnives.test.showcase.core.session package org.fnives.test.showcase.core.session
import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener
import javax.inject.Inject
internal class SessionExpirationAdapter( internal class SessionExpirationAdapter @Inject constructor(
private val sessionExpirationListener: SessionExpirationListener private val sessionExpirationListener: SessionExpirationListener
) : ) : NetworkSessionExpirationListener {
NetworkSessionExpirationListener {
override fun onSessionExpired() = sessionExpirationListener.onSessionExpired() 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.model.session.Session
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import javax.inject.Inject
internal class NetworkSessionLocalStorageAdapter( internal class NetworkSessionLocalStorageAdapter @Inject constructor(
private val userDataLocalStorage: UserDataLocalStorage private val userDataLocalStorage: UserDataLocalStorage
) : NetworkSessionLocalStorage { ) : 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 kotlinx.coroutines.test.runBlockingTest
import org.fnives.test.showcase.core.content.ContentRepository 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.core.storage.UserDataLocalStorage
import org.fnives.test.showcase.model.network.BaseUrl import org.fnives.test.showcase.model.network.BaseUrl
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
@ -27,7 +28,7 @@ internal class LogoutUseCaseTest : KoinTest {
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
mockUserDataLocalStorage = mock() mockUserDataLocalStorage = mock()
sut = LogoutUseCase(mockUserDataLocalStorage) sut = LogoutUseCase(mockUserDataLocalStorage, null)
startKoin { startKoin {
modules( modules(
createCoreModule( createCoreModule(

View file

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

View file

@ -7,3 +7,7 @@ java {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = 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" implementation "com.squareup.moshi:moshi:$moshi_version"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
// koin
api "io.insert-koin:koin-core:$koin_version" api "io.insert-koin:koin-core:$koin_version"
// hilt
implementation "com.google.dagger:hilt-core:$hilt_version"
api project(":model") api project(":model")
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
@ -28,4 +33,6 @@ dependencies {
testImplementation "io.insert-koin:koin-test-junit5:$koin_version" testImplementation "io.insert-koin:koin-test-junit5:$koin_version"
testImplementation "org.skyscreamer:jsonassert:$testing_json_assert_version" testImplementation "org.skyscreamer:jsonassert:$testing_json_assert_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_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 org.fnives.test.showcase.network.shared.exceptions.ParsingException
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import javax.inject.Inject
internal class LoginErrorConverter { internal class LoginErrorConverter @Inject constructor() {
@Throws(ParsingException::class) @Throws(ParsingException::class)
suspend fun invoke(request: suspend () -> Response<LoginResponse>): LoginStatusResponses = 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.ExceptionWrapper
import org.fnives.test.showcase.network.shared.exceptions.NetworkException import org.fnives.test.showcase.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.network.shared.exceptions.ParsingException 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 loginService: LoginService,
private val loginErrorConverter: LoginErrorConverter private val loginErrorConverter: LoginErrorConverter
) : LoginRemoteSource { ) : 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.ContentId
import org.fnives.test.showcase.model.content.ImageUrl import org.fnives.test.showcase.model.content.ImageUrl
import org.fnives.test.showcase.network.shared.ExceptionWrapper 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> = override suspend fun get(): List<Content> =
ExceptionWrapper.wrap { 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 okhttp3.OkHttpClient
import org.fnives.test.showcase.model.network.BaseUrl 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.ContentRemoteSource
import org.fnives.test.showcase.network.content.ContentRemoteSourceImpl import org.fnives.test.showcase.network.content.ContentRemoteSourceImpl
import org.fnives.test.showcase.network.content.ContentService 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.AuthenticationHeaderInterceptor
import org.fnives.test.showcase.network.session.AuthenticationHeaderUtils import org.fnives.test.showcase.network.session.AuthenticationHeaderUtils
import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener

View file

@ -1,14 +1,18 @@
package org.fnives.test.showcase.network.session package org.fnives.test.showcase.network.session
import okhttp3.Request 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 = fun hasToken(okhttpRequest: Request): Boolean =
okhttpRequest.header(KEY) == networkSessionLocalStorage.session?.accessToken okhttpRequest.header(KEY) == networkSessionLocalStorage.session?.accessToken
fun attachToken(okhttpRequest: Request): Request = 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 { companion object {
private const val KEY = "Authorization" private const val KEY = "Authorization"

View file

@ -6,8 +6,9 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.Route import okhttp3.Route
import org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl 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 networkSessionLocalStorage: NetworkSessionLocalStorage,
private val loginRemoteSource: LoginRemoteSourceImpl, private val loginRemoteSource: LoginRemoteSourceImpl,
private val authenticationHeaderUtils: AuthenticationHeaderUtils, 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 kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.network.BaseUrl 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.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage 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 kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.auth.LoginCredentials import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.network.BaseUrl 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.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
import org.fnives.test.showcase.network.mockserver.ContentData.createExpectedLoginRequestJson 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.mockserver.scenario.auth.AuthScenario

View file

@ -3,7 +3,7 @@ package org.fnives.test.showcase.network.content
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
import org.fnives.test.showcase.model.network.BaseUrl 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.NetworkSessionExpirationListener
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage
import org.junit.jupiter.api.AfterEach 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 kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.network.BaseUrl 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.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage 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 kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.model.network.BaseUrl import org.fnives.test.showcase.model.network.BaseUrl
import org.fnives.test.showcase.model.session.Session 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.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario 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.mockserver.scenario.refresh.RefreshTokenScenario