diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..276f837 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +app/app.iml +.idea/ +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +*.jks +*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +lint/reports/ \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..2a7b3cf --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,144 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' +} + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.3" + + defaultConfig { + applicationId "org.fnives.test.showcase" + minSdkVersion 21 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + buildConfigField "String", "BASE_URL", '"https://606844a10add49001733fe6b.mockapi.io/"' + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + buildFeatures { + viewBinding true + } + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + androidTest { + java.srcDirs += "src/sharedTest/java" + } + test { + java.srcDirs += "src/sharedTest/java" + java.srcDirs += "src/robolectricTest/java" + } + } + + testOptions.unitTests.all { + useJUnitPlatform() + testLogging { + events 'started', 'passed', 'skipped', 'failed' + exceptionFormat "full" + showStandardStreams true + } + } + testOptions { + unitTests { + includeAndroidResources = true + } + } + // needed for androidTest + packagingOptions { + exclude 'META-INF/LGPL2.1' + exclude 'META-INF/AL2.0' + exclude 'META-INF/LICENSE.md' + exclude 'META-INF/LICENSE-notice.md' + } + + lintOptions { + warningsAsErrors true + abortOnError true + textReport true + ignore 'Overdraw' + textOutput "stdout" + } +} + +afterEvaluate { + // making sure the :mockserver is assembled after :clean when running tests + testDebugUnitTest.dependsOn tasks.getByPath(':mockserver:assemble') + testReleaseUnitTest.dependsOn tasks.getByPath(':mockserver:assemble') +} + +dependencies { + + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "androidx.core:core-ktx:$androidx_core_version" + implementation "androidx.appcompat:appcompat:$androidx_appcompat_version" + implementation "com.google.android.material:material:$androidx_material_version" + implementation "androidx.constraintlayout:constraintlayout:$androidx_constraintlayout_version" + implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:$androidx_livedata_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidx_livedata_version" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidx_swiperefreshlayout_version" + + // Koin + implementation "org.koin:koin-androidx-scope:$koin_version" + implementation "org.koin:koin-androidx-viewmodel:$koin_version" + implementation "org.koin:koin-androidx-fragment:$koin_version" + + implementation "androidx.room:room-runtime:$androidx_room_version" + kapt "androidx.room:room-compiler:$androidx_room_version" + implementation "androidx.room:room-ktx:$androidx_room_version" + + implementation "io.coil-kt:coil:$coil_version" + + implementation project(":core") + releaseImplementation project(":core") + + testImplementation "androidx.room:room-testing:$androidx_room_version" + testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" + testImplementation "org.junit.jupiter:junit-jupiter-params:$testing_junit5_version" + testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + testImplementation "com.jraska.livedata:testing-ktx:$testing_livedata_version" + testImplementation "org.koin:koin-test:$koin_version" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" + + // robolectric specific + testImplementation "junit:junit:$testing_junit4_version" + testImplementation "org.robolectric:robolectric:$testing_robolectric_version" + testImplementation "androidx.test:core:$testing_androidx_code_version" + testImplementation "androidx.test:runner:$testing_androidx_code_version" + testImplementation "androidx.test.ext:junit:$testing_androidx_junit_version" + testImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version" + testImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version" + testImplementation project(':mockserver') + testImplementation "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version" + testImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version" + testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version" + + androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + androidTestImplementation "org.koin:koin-test:$koin_version" + androidTestImplementation "junit:junit:$testing_junit4_version" + androidTestImplementation "androidx.test:core:$testing_androidx_code_version" + androidTestImplementation "androidx.test:runner:$testing_androidx_code_version" + androidTestImplementation "androidx.test.ext:junit:$testing_androidx_junit_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$testing_espresso_version" + androidTestImplementation "androidx.test.espresso:espresso-intents:$testing_espresso_version" + androidTestImplementation project(':mockserver') + androidTestImplementation "com.jakewharton.espresso:okhttp3-idling-resource:$testing_okhttp3_idling_resource_version" + androidTestImplementation "androidx.arch.core:core-testing:$testing_androidx_arch_core_version" + androidTestRuntimeOnly "org.junit.vintage:junit-vintage-engine:$testing_junit5_version" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestLoginRobotConfiguration.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestLoginRobotConfiguration.kt new file mode 100644 index 0000000..edccc1f --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestLoginRobotConfiguration.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.testutils.configuration + +object AndroidTestLoginRobotConfiguration : LoginRobotConfiguration { + override val assertLoadingBeforeRequest: Boolean get() = false +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestMainDispatcherTestRule.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestMainDispatcherTestRule.kt new file mode 100644 index 0000000..c2f11f5 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestMainDispatcherTestRule.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.testutils.configuration + +import androidx.test.espresso.Espresso +import androidx.test.espresso.NoActivityResumedException +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import kotlinx.coroutines.Dispatchers +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.fnives.test.showcase.testutils.idling.loopMainThreadFor +import org.fnives.test.showcase.testutils.idling.loopMainThreadUntilIdleWithIdlingResources +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class AndroidTestMainDispatcherTestRule : MainDispatcherTestRule { + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + DatabaseInitialization.dispatcher = Dispatchers.Main.immediate + base.evaluate() + } + } + + override fun advanceUntilIdleWithIdlingResources() { + loopMainThreadUntilIdleWithIdlingResources() + } + + override fun advanceUntilIdleOrActivityIsDestroyed() { + try { + advanceUntilIdleWithIdlingResources() + Espresso.onView(ViewMatchers.isRoot()).check(ViewAssertions.doesNotExist()) + } catch (noActivityResumedException: NoActivityResumedException) { + // expected to happen + } catch (runtimeException: RuntimeException) { + if (runtimeException.message?.contains("No activities found") == true) { + // expected to happen + } else { + throw runtimeException + } + } + } + + override fun advanceUntilIdle() { + loopMainThreadUntilIdleWithIdlingResources() + } + + override fun advanceTimeBy(delayInMillis: Long) { + loopMainThreadFor(delayInMillis) + } +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestServerTypeConfiguration.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestServerTypeConfiguration.kt new file mode 100644 index 0000000..46a59a3 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestServerTypeConfiguration.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.testutils.configuration + +import okhttp3.OkHttpClient +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.testutils.idling.NetworkSynchronization +import org.koin.core.context.loadKoinModules +import org.koin.core.qualifier.StringQualifier +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.get + +object AndroidTestServerTypeConfiguration : ServerTypeConfiguration, KoinTest { + 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 + val sessionless = StringQualifier(NetworkSynchronization.OkHttpClientTypes.SESSIONLESS.qualifier) + val okHttpClientWithCertificate = get(sessionless).newBuilder() + .sslSocketFactory(handshakeCertificates.sslSocketFactory(), handshakeCertificates.trustManager) + .build() + loadKoinModules( + module { + single(qualifier = sessionless, override = true) { okHttpClientWithCertificate } + } + ) + } +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationTestRule.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationTestRule.kt new file mode 100644 index 0000000..9a12237 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/AndroidTestSnackbarVerificationTestRule.kt @@ -0,0 +1,44 @@ +package org.fnives.test.showcase.testutils.configuration + +import android.view.View +import androidx.annotation.StringRes +import androidx.test.espresso.Espresso +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import com.google.android.material.R +import com.google.android.material.snackbar.Snackbar +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.junit.runner.Description +import org.junit.runners.model.Statement + +object AndroidTestSnackbarVerificationTestRule : SnackbarVerificationTestRule { + + override fun apply(base: Statement, description: Description): Statement = base + + override fun assertIsShownWithText(@StringRes stringResID: Int) { + Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)) + .check(ViewAssertions.matches(ViewMatchers.withText(stringResID))) + Espresso.onView(ViewMatchers.isAssignableFrom(Snackbar.SnackbarLayout::class.java)).perform(ViewActions.swipeRight()) + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainUntilSnackbarDismissed()) + } + + override fun assertIsNotShown() { + Espresso.onView(ViewMatchers.withId(R.id.snackbar_text)).check(ViewAssertions.doesNotExist()) + } + + class LoopMainUntilSnackbarDismissed() : ViewAction { + override fun getConstraints(): Matcher = Matchers.isA(View::class.java) + + override fun getDescription(): String = "loop MainThread until Snackbar is Dismissed" + + override fun perform(uiController: UiController, view: View?) { + while (view?.findViewById(com.google.android.material.R.id.snackbar_text) != null) { + uiController.loopMainThreadForAtLeast(100) + } + } + } +} diff --git a/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt new file mode 100644 index 0000000..af65008 --- /dev/null +++ b/app/src/androidTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.testutils.configuration + +object SpecificTestConfigurationsFactory : TestConfigurationsFactory { + override fun createMainDispatcherTestRule(): MainDispatcherTestRule = + AndroidTestMainDispatcherTestRule() + + override fun createServerTypeConfiguration(): ServerTypeConfiguration = + AndroidTestServerTypeConfiguration + + override fun createLoginRobotConfiguration(): LoginRobotConfiguration = + AndroidTestLoginRobotConfiguration + + override fun createSnackbarVerification(): SnackbarVerificationTestRule = + AndroidTestSnackbarVerificationTestRule +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1000bd1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..8630fd1 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/org/fnives/test/showcase/TestShowcaseApplication.kt b/app/src/main/java/org/fnives/test/showcase/TestShowcaseApplication.kt new file mode 100644 index 0000000..60b05b4 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/TestShowcaseApplication.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase + +import android.app.Application +import org.fnives.test.showcase.di.BaseUrlProvider +import org.fnives.test.showcase.di.createAppModules +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +class TestShowcaseApplication : Application() { + + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@TestShowcaseApplication) + modules(createAppModules(BaseUrlProvider.get())) + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/di/BaseUrlProvider.kt b/app/src/main/java/org/fnives/test/showcase/di/BaseUrlProvider.kt new file mode 100644 index 0000000..9b5e5ba --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/di/BaseUrlProvider.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.di + +import org.fnives.test.showcase.BuildConfig +import org.fnives.test.showcase.model.network.BaseUrl + +object BaseUrlProvider { + + fun get() = BaseUrl(BuildConfig.BASE_URL) +} diff --git a/app/src/main/java/org/fnives/test/showcase/di/createAppModules.kt b/app/src/main/java/org/fnives/test/showcase/di/createAppModules.kt new file mode 100644 index 0000000..8d05715 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/di/createAppModules.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.di + +import org.fnives.test.showcase.core.di.createCoreModule +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.session.SessionExpirationListenerImpl +import org.fnives.test.showcase.storage.LocalDatabase +import org.fnives.test.showcase.storage.SharedPreferencesManagerImpl +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.fnives.test.showcase.storage.favourite.FavouriteContentLocalStorageImpl +import org.fnives.test.showcase.ui.auth.AuthViewModel +import org.fnives.test.showcase.ui.home.MainViewModel +import org.fnives.test.showcase.ui.splash.SplashViewModel +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.Module +import org.koin.dsl.module + +fun createAppModules(baseUrl: BaseUrl): List { + return createCoreModule( + baseUrl = baseUrl, + true, + userDataLocalStorageProvider = { get() }, + sessionExpirationListenerProvider = { get() }, + favouriteContentLocalStorageProvider = { get() } + ) + .plus(storageModule()) + .plus(authModule()) + .plus(appModule()) + .plus(favouriteModule()) + .plus(splashModule()) + .toList() +} + +fun storageModule() = module { + single { SharedPreferencesManagerImpl.create(androidContext()) } + single { DatabaseInitialization.create(androidContext()) } +} + +fun authModule() = module { + viewModel { AuthViewModel(get()) } +} + +fun appModule() = module { + single { SessionExpirationListenerImpl(androidContext()) } +} + +fun splashModule() = module { + viewModel { SplashViewModel(get()) } +} + +fun favouriteModule() = module { + single { get().favouriteDao } + viewModel { MainViewModel(get(), get(), get(), get(), get()) } + single { FavouriteContentLocalStorageImpl(get()) } +} diff --git a/app/src/main/java/org/fnives/test/showcase/session/SessionExpirationListenerImpl.kt b/app/src/main/java/org/fnives/test/showcase/session/SessionExpirationListenerImpl.kt new file mode 100644 index 0000000..230a521 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/session/SessionExpirationListenerImpl.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.session + +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import org.fnives.test.showcase.core.session.SessionExpirationListener +import org.fnives.test.showcase.ui.auth.AuthActivity + +class SessionExpirationListenerImpl(private val context: Context) : SessionExpirationListener { + + override fun onSessionExpired() { + Handler(Looper.getMainLooper()).post { + context.startActivity( + AuthActivity.getStartIntent(context) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt b/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt new file mode 100644 index 0000000..3a78802 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/LocalDatabase.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.storage + +import androidx.room.Database +import androidx.room.RoomDatabase +import org.fnives.test.showcase.storage.favourite.FavouriteDao +import org.fnives.test.showcase.storage.favourite.FavouriteEntity + +@Database(entities = [FavouriteEntity::class], version = 1, exportSchema = false) +abstract class LocalDatabase : RoomDatabase() { + + abstract val favouriteDao: FavouriteDao +} diff --git a/app/src/main/java/org/fnives/test/showcase/storage/SharedPreferencesManagerImpl.kt b/app/src/main/java/org/fnives/test/showcase/storage/SharedPreferencesManagerImpl.kt new file mode 100644 index 0000000..a62af66 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/SharedPreferencesManagerImpl.kt @@ -0,0 +1,58 @@ +package org.fnives.test.showcase.storage + +import android.content.Context +import android.content.SharedPreferences +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class SharedPreferencesManagerImpl(private val sharedPreferences: SharedPreferences) : UserDataLocalStorage { + + override var session: Session? by SessionDelegate(SESSION_KEY) + + private class SessionDelegate(private val key: String) : ReadWriteProperty { + + override fun setValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>, value: Session?) { + if (value == null) { + thisRef.sharedPreferences.edit().remove(key).apply() + } else { + val values = setOf( + ACCESS_TOKEN_KEY + value.accessToken, + REFRESH_TOKEN_KEY + value.refreshToken + ) + thisRef.sharedPreferences.edit().putStringSet(key, values).apply() + } + } + + override fun getValue(thisRef: SharedPreferencesManagerImpl, property: KProperty<*>): Session? { + val values = thisRef.sharedPreferences.getStringSet(key, null)?.toList() + val accessToken = values?.firstOrNull { it.startsWith(ACCESS_TOKEN_KEY) } + ?.drop(ACCESS_TOKEN_KEY.length) ?: return null + val refreshToken = values.firstOrNull { it.startsWith(REFRESH_TOKEN_KEY) } + ?.drop(REFRESH_TOKEN_KEY.length) ?: return null + + return Session(accessToken = accessToken, refreshToken = refreshToken) + } + + companion object { + private const val ACCESS_TOKEN_KEY = "ACCESS_TOKEN_KEY" + private const val REFRESH_TOKEN_KEY = "REFRESH_TOKEN_KEY" + } + } + + companion object { + + private const val SESSION_KEY = "SESSION_KEY" + private const val SESSION_SHARED_PREFERENCES_NAME = "SESSION_SHARED_PREFERENCES_NAME" + + fun create(context: Context): SharedPreferencesManagerImpl { + val sharedPreferences = context.getSharedPreferences( + SESSION_SHARED_PREFERENCES_NAME, + Context.MODE_PRIVATE + ) + + return SharedPreferencesManagerImpl(sharedPreferences) + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt b/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt new file mode 100644 index 0000000..02606d0 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.storage.database + +import android.content.Context +import androidx.room.Room +import org.fnives.test.showcase.storage.LocalDatabase + +object DatabaseInitialization { + + fun create(context: Context): LocalDatabase = + Room.databaseBuilder(context, LocalDatabase::class.java, "local_database") + .allowMainThreadQueries() + .build() +} diff --git a/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImpl.kt b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImpl.kt new file mode 100644 index 0000000..ac0f9d9 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteContentLocalStorageImpl.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.storage.favourite + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId + +class FavouriteContentLocalStorageImpl(private val favouriteDao: FavouriteDao) : FavouriteContentLocalStorage { + override fun observeFavourites(): Flow> = + favouriteDao.get().map { it.map(FavouriteEntity::contentId).map(::ContentId) } + + override suspend fun markAsFavourite(contentId: ContentId) { + favouriteDao.addFavourite(FavouriteEntity(contentId.id)) + } + + override suspend fun deleteAsFavourite(contentId: ContentId) { + favouriteDao.deleteFavourite(FavouriteEntity(contentId.id)) + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteDao.kt b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteDao.kt new file mode 100644 index 0000000..93d48be --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteDao.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.storage.favourite + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface FavouriteDao { + + @Query("SELECT * FROM FavouriteEntity") + fun get(): Flow> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun addFavourite(favouriteEntity: FavouriteEntity) + + @Delete + suspend fun deleteFavourite(favouriteEntity: FavouriteEntity) +} diff --git a/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt new file mode 100644 index 0000000..1d2a280 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/storage/favourite/FavouriteEntity.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.storage.favourite + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class FavouriteEntity(@PrimaryKey val contentId: String) diff --git a/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthActivity.kt b/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthActivity.kt new file mode 100644 index 0000000..3e4157c --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthActivity.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.ui.auth + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import com.google.android.material.snackbar.Snackbar +import org.fnives.test.showcase.R +import org.fnives.test.showcase.databinding.ActivityAuthenticationBinding +import org.fnives.test.showcase.ui.home.MainActivity +import org.koin.androidx.viewmodel.ext.android.viewModel + +class AuthActivity : AppCompatActivity() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityAuthenticationBinding.inflate(layoutInflater) + viewModel.loading.observe(this) { + binding.loadingIndicator.isVisible = it == true + } + viewModel.password.observe(this, SetTextIfNotSameObserver(binding.passwordEditText)) + binding.passwordEditText.doAfterTextChanged { viewModel.onPasswordChanged(it?.toString().orEmpty()) } + viewModel.username.observe(this, SetTextIfNotSameObserver(binding.userEditText)) + binding.userEditText.doAfterTextChanged { viewModel.onUsernameChanged(it?.toString().orEmpty()) } + binding.loginCta.setOnClickListener { + viewModel.onLogin() + } + viewModel.error.observe(this) { + val stringResId = it?.consume()?.stringResId() ?: return@observe + Snackbar.make(binding.snackbarHolder, stringResId, Snackbar.LENGTH_LONG).show() + } + viewModel.navigateToHome.observe(this) { + it.consume() ?: return@observe + startActivity(MainActivity.getStartIntent(this)) + finishAffinity() + } + setContentView(binding.root) + } + + companion object { + + private fun AuthViewModel.ErrorType.stringResId() = when (this) { + AuthViewModel.ErrorType.INVALID_CREDENTIALS -> R.string.credentials_invalid + AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR -> R.string.something_went_wrong + AuthViewModel.ErrorType.UNSUPPORTED_USERNAME -> R.string.username_is_invalid + AuthViewModel.ErrorType.UNSUPPORTED_PASSWORD -> R.string.password_is_invalid + } + + fun getStartIntent(context: Context): Intent = Intent(context, AuthActivity::class.java) + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthViewModel.kt b/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..0221f14 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/auth/AuthViewModel.kt @@ -0,0 +1,66 @@ +package org.fnives.test.showcase.ui.auth + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.fnives.test.showcase.core.login.LoginUseCase +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.ui.shared.Event + +class AuthViewModel(private val loginUseCase: LoginUseCase) : ViewModel() { + + private val _username = MutableLiveData() + val username: LiveData = _username + private val _password = MutableLiveData() + val password: LiveData = _password + private val _loading = MutableLiveData(false) + val loading: LiveData = _loading + private val _error = MutableLiveData>() + val error: LiveData> = _error + private val _navigateToHome = MutableLiveData>() + val navigateToHome: LiveData> = _navigateToHome + + fun onPasswordChanged(password: String) { + _password.value = password + } + + fun onUsernameChanged(username: String) { + _username.value = username + } + + fun onLogin() { + if (_loading.value == true) return + _loading.value = true + viewModelScope.launch { + val credentials = LoginCredentials( + username = _username.value.orEmpty(), + password = _password.value.orEmpty() + ) + when (val response = loginUseCase.invoke(credentials)) { + is Answer.Error -> _error.value = Event(ErrorType.GENERAL_NETWORK_ERROR) + is Answer.Success -> processLoginStatus(response.data) + } + _loading.postValue(false) + } + } + + private fun processLoginStatus(loginStatus: LoginStatus) { + when (loginStatus) { + LoginStatus.SUCCESS -> _navigateToHome.value = Event(Unit) + LoginStatus.INVALID_CREDENTIALS -> _error.value = Event(ErrorType.INVALID_CREDENTIALS) + LoginStatus.INVALID_USERNAME -> _error.value = Event(ErrorType.UNSUPPORTED_USERNAME) + LoginStatus.INVALID_PASSWORD -> _error.value = Event(ErrorType.UNSUPPORTED_PASSWORD) + } + } + + enum class ErrorType { + INVALID_CREDENTIALS, + GENERAL_NETWORK_ERROR, + UNSUPPORTED_USERNAME, + UNSUPPORTED_PASSWORD + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/auth/SetTextIfNotSameObserver.kt b/app/src/main/java/org/fnives/test/showcase/ui/auth/SetTextIfNotSameObserver.kt new file mode 100644 index 0000000..ff48bea --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/auth/SetTextIfNotSameObserver.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.ui.auth + +import android.widget.EditText +import androidx.lifecycle.Observer + +class SetTextIfNotSameObserver(private val editText: EditText) : Observer { + override fun onChanged(t: String?) { + val current = editText.text?.toString() + if (current != t) { + editText.setText(t) + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/home/FavouriteContentAdapter.kt b/app/src/main/java/org/fnives/test/showcase/ui/home/FavouriteContentAdapter.kt new file mode 100644 index 0000000..a4e5b58 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/home/FavouriteContentAdapter.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.ui.home + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.fnives.test.showcase.R +import org.fnives.test.showcase.databinding.ItemFavouriteContentBinding +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.ui.shared.ViewBindingAdapter +import org.fnives.test.showcase.ui.shared.layoutInflater +import org.fnives.test.showcase.ui.shared.loadRoundedImage + +class FavouriteContentAdapter( + private val listener: OnFavouriteItemClicked, +) : ListAdapter>( + DiffUtilItemCallback() +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewBindingAdapter = + ViewBindingAdapter(ItemFavouriteContentBinding.inflate(parent.layoutInflater(), parent, false)).apply { + viewBinding.favouriteCta.setOnClickListener { + if (adapterPosition in 0 until itemCount) { + listener.onFavouriteToggleClicked(getItem(adapterPosition).content.id) + } + } + } + + override fun onBindViewHolder(holder: ViewBindingAdapter, position: Int) { + val item = getItem(position) + holder.viewBinding.img.loadRoundedImage(item.content.imageUrl) + holder.viewBinding.title.text = item.content.title + holder.viewBinding.description.text = item.content.description + val favouriteResId = if (item.isFavourite) R.drawable.favorite_24 else R.drawable.favorite_border_24 + holder.viewBinding.favouriteCta.setImageResource(favouriteResId) + } + + interface OnFavouriteItemClicked { + fun onFavouriteToggleClicked(contentId: ContentId) + } + + class DiffUtilItemCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean = + oldItem.content.id == newItem.content.id + + override fun areContentsTheSame(oldItem: FavouriteContent, newItem: FavouriteContent): Boolean = + oldItem == newItem + + override fun getChangePayload(oldItem: FavouriteContent, newItem: FavouriteContent): Any? = oldItem + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/home/MainActivity.kt b/app/src/main/java/org/fnives/test/showcase/ui/home/MainActivity.kt new file mode 100644 index 0000000..aef19a9 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/home/MainActivity.kt @@ -0,0 +1,68 @@ +package org.fnives.test.showcase.ui.home + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import org.fnives.test.showcase.R +import org.fnives.test.showcase.databinding.ActivityMainBinding +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.fnives.test.showcase.ui.shared.VerticalSpaceItemDecoration +import org.fnives.test.showcase.ui.shared.getThemePrimaryColor +import org.koin.androidx.viewmodel.ext.android.viewModel + +class MainActivity : AppCompatActivity() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityMainBinding.inflate(layoutInflater) + binding.toolbar.menu?.findItem(R.id.logout_cta)?.setOnMenuItemClickListener { + viewModel.onLogout() + true + } + binding.swipeRefreshLayout.setColorSchemeColors(binding.swipeRefreshLayout.getThemePrimaryColor()) + binding.swipeRefreshLayout.setOnRefreshListener { + viewModel.onRefresh() + } + + val adapter = FavouriteContentAdapter(viewModel.mapToAdapterListener()) + binding.recycler.layoutManager = LinearLayoutManager(this) + binding.recycler.addItemDecoration(VerticalSpaceItemDecoration(resources.getDimensionPixelOffset(R.dimen.padding))) + binding.recycler.adapter = adapter + + viewModel.content.observe(this) { + adapter.submitList(it.orEmpty()) + } + viewModel.errorMessage.observe(this) { + binding.errorMessage.isVisible = it == true + } + viewModel.navigateToAuth.observe(this) { + it.consume() ?: return@observe + startActivity(AuthActivity.getStartIntent(this)) + finishAffinity() + } + viewModel.loading.observe(this) { + if (binding.swipeRefreshLayout.isRefreshing != it) { + binding.swipeRefreshLayout.isRefreshing = it == true + } + } + + setContentView(binding.root) + } + + companion object { + fun getStartIntent(context: Context): Intent = Intent(context, MainActivity::class.java) + + private fun MainViewModel.mapToAdapterListener(): FavouriteContentAdapter.OnFavouriteItemClicked = + object : FavouriteContentAdapter.OnFavouriteItemClicked { + override fun onFavouriteToggleClicked(contentId: ContentId) { + this@mapToAdapterListener.onFavouriteToggleClicked(contentId) + } + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/home/MainViewModel.kt b/app/src/main/java/org/fnives/test/showcase/ui/home/MainViewModel.kt new file mode 100644 index 0000000..326ac01 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/home/MainViewModel.kt @@ -0,0 +1,81 @@ +package org.fnives.test.showcase.ui.home + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.core.content.FetchContentUseCase +import org.fnives.test.showcase.core.content.GetAllContentUseCase +import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.core.login.LogoutUseCase +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.ui.shared.Event + +class MainViewModel( + private val getAllContentUseCase: GetAllContentUseCase, + private val logoutUseCase: LogoutUseCase, + private val fetchContentUseCase: FetchContentUseCase, + private val addContentToFavouriteUseCase: AddContentToFavouriteUseCase, + private val removeContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase +) : ViewModel() { + + private val _loading = MutableLiveData() + val loading: LiveData = _loading + private val _content: LiveData> = liveData { + getAllContentUseCase.get().collect { + when (it) { + is Resource.Error -> { + _errorMessage.value = true + _loading.value = false + emit(emptyList()) + } + is Resource.Loading -> { + _errorMessage.value = false + _loading.value = true + } + is Resource.Success -> { + _errorMessage.value = false + _loading.value = false + emit(it.data) + } + } + } + } + val content: LiveData> = _content + private val _errorMessage = MutableLiveData(false) + val errorMessage: LiveData = _errorMessage + private val _navigateToAuth = MutableLiveData>() + val navigateToAuth: LiveData> = _navigateToAuth + + fun onLogout() { + viewModelScope.launch { + logoutUseCase.invoke() + _navigateToAuth.value = Event(Unit) + } + } + + fun onRefresh() { + if (_loading.value == true) return + _loading.value = true + viewModelScope.launch { + fetchContentUseCase.invoke() + } + } + + fun onFavouriteToggleClicked(contentId: ContentId) { + viewModelScope.launch { + val content = _content.value?.firstOrNull { it.content.id == contentId } ?: return@launch + if (content.isFavourite) { + removeContentFromFavouritesUseCase.invoke(contentId) + } else { + addContentToFavouriteUseCase.invoke(contentId) + } + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt new file mode 100644 index 0000000..30a323c --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/Event.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.ui.shared + +@Suppress("DataClassContainsFunctions") +data class Event(private val data: T) { + + private var consumed: Boolean = false + + fun consume(): T? = data.takeUnless { consumed }?.also { consumed = true } + + fun peek() = data +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/shared/VerticalSpaceItemDecoration.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/VerticalSpaceItemDecoration.kt new file mode 100644 index 0000000..bdc452b --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/VerticalSpaceItemDecoration.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.ui.shared + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int) : ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + outRect.set(0, 0, 0, verticalSpaceHeight) + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewBindingAdapter.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewBindingAdapter.kt new file mode 100644 index 0000000..719a4aa --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewBindingAdapter.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.ui.shared + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +class ViewBindingAdapter(val viewBinding: T) : RecyclerView.ViewHolder(viewBinding.root) diff --git a/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewExtension.kt b/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewExtension.kt new file mode 100644 index 0000000..56d514c --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/shared/ViewExtension.kt @@ -0,0 +1,24 @@ +package org.fnives.test.showcase.ui.shared + +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import coil.load +import coil.transform.RoundedCornersTransformation +import org.fnives.test.showcase.R +import org.fnives.test.showcase.model.content.ImageUrl + +fun View.layoutInflater(): LayoutInflater = LayoutInflater.from(context) + +fun ImageView.loadRoundedImage(imageUrl: ImageUrl) { + load(imageUrl.url) { + transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.rounded_corner))) + } +} + +fun View.getThemePrimaryColor(): Int { + val value = TypedValue() + context.theme.resolveAttribute(R.attr.colorPrimary, value, true) + return value.data +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivity.kt b/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivity.kt new file mode 100644 index 0000000..ab7ee68 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashActivity.kt @@ -0,0 +1,27 @@ +package org.fnives.test.showcase.ui.splash + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import org.fnives.test.showcase.R +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.fnives.test.showcase.ui.home.MainActivity +import org.koin.androidx.viewmodel.ext.android.viewModel + +class SplashActivity : AppCompatActivity() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_splash) + viewModel.navigateTo.observe(this) { + val intent = when (it.consume()) { + SplashViewModel.NavigateTo.HOME -> MainActivity.getStartIntent(this) + SplashViewModel.NavigateTo.AUTHENTICATION -> AuthActivity.getStartIntent(this) + null -> return@observe + } + startActivity(intent) + finishAffinity() + } + } +} diff --git a/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashViewModel.kt b/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashViewModel.kt new file mode 100644 index 0000000..d9bbd43 --- /dev/null +++ b/app/src/main/java/org/fnives/test/showcase/ui/splash/SplashViewModel.kt @@ -0,0 +1,28 @@ +package org.fnives.test.showcase.ui.splash + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.ui.shared.Event + +class SplashViewModel(isUserLoggedInUseCase: IsUserLoggedInUseCase) : ViewModel() { + + private val _navigateTo = MutableLiveData>() + val navigateTo: LiveData> = _navigateTo + + init { + viewModelScope.launch { + delay(500L) + val navigationEvent = if (isUserLoggedInUseCase.invoke()) NavigateTo.HOME else NavigateTo.AUTHENTICATION + _navigateTo.value = Event(navigationEvent) + } + } + + enum class NavigateTo { + HOME, AUTHENTICATION + } +} diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..da1dcd9 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/favorite_24.xml b/app/src/main/res/drawable/favorite_24.xml new file mode 100644 index 0000000..209e42e --- /dev/null +++ b/app/src/main/res/drawable/favorite_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/favorite_border_24.xml b/app/src/main/res/drawable/favorite_border_24.xml new file mode 100644 index 0000000..83e57ce --- /dev/null +++ b/app/src/main/res/drawable/favorite_border_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b219d51 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/logout_24.xml b/app/src/main/res/drawable/logout_24.xml new file mode 100644 index 0000000..77928df --- /dev/null +++ b/app/src/main/res/drawable/logout_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/activity_authentication.xml b/app/src/main/res/layout/activity_authentication.xml new file mode 100644 index 0000000..f012327 --- /dev/null +++ b/app/src/main/res/layout/activity_authentication.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..908365c --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_splash.xml b/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..c758e5a --- /dev/null +++ b/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_favourite_content.xml b/app/src/main/res/layout/item_favourite_content.xml new file mode 100644 index 0000000..c4115ca --- /dev/null +++ b/app/src/main/res/layout/item_favourite_content.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml new file mode 100644 index 0000000..f42dec2 --- /dev/null +++ b/app/src/main/res/menu/main.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..1d4a022 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..d883c08 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..2c7df4c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..5aea0c0 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..aa9c115 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..85f4ace Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..c0e0682 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..969e3a6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ce478dc Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..c4098f9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..4e197d3 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..67abbea --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,11 @@ + + + 24dp + 56dp + 16dp + 8dp + 120dp + 6dp + 48dp + 12dp + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f84909a --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + Test ShowCase + Login + Username + Password + Username is not filled properly! + Password is not filled properly! + No User with given credentials! + Something went wrong! + Mock Login + Content + Logout + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..64b01cd --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt new file mode 100644 index 0000000..da79993 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/favourite/FavouriteContentLocalStorageImplTest.kt @@ -0,0 +1,91 @@ +package org.fnives.test.showcase.favourite + +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.inject + +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +internal class FavouriteContentLocalStorageImplTest : KoinTest { + + private val sut by inject() + private lateinit var testDispatcher: TestCoroutineDispatcher + + @Before + fun setUp() { + testDispatcher = TestCoroutineDispatcher() + DatabaseInitialization.dispatcher = testDispatcher + } + + @After + fun tearDown() { + stopKoin() + } + + @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() + 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 { + val expected = listOf(listOf(), listOf(ContentId("a"))) + + 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 { + val expected = listOf(listOf(ContentId("a")), listOf()) + sut.markAsFavourite(ContentId("a")) + + val actual = async(testDispatcher) { + sut.observeFavourites().take(2).toList() + } + testDispatcher.advanceUntilIdle() + + sut.deleteAsFavourite(ContentId("a")) + + Assert.assertEquals(expected, actual.await()) + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricLoginRobotConfiguration.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricLoginRobotConfiguration.kt new file mode 100644 index 0000000..dbe6927 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricLoginRobotConfiguration.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.testutils.configuration + +object RobolectricLoginRobotConfiguration : LoginRobotConfiguration { + override val assertLoadingBeforeRequest: Boolean = true +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricServerTypeConfiguration.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricServerTypeConfiguration.kt new file mode 100644 index 0000000..895f466 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricServerTypeConfiguration.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.testutils.configuration + +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup + +object RobolectricServerTypeConfiguration : ServerTypeConfiguration { + override val useHttps: Boolean = false + + override val url: String get() = "${MockServerScenarioSetup.HTTP_BASE_URL}:${MockServerScenarioSetup.PORT}/" + + override fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) = Unit +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationTestRule.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationTestRule.kt new file mode 100644 index 0000000..565a518 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/RobolectricSnackbarVerificationTestRule.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.testutils.configuration + +import androidx.annotation.StringRes +import org.fnives.test.showcase.testutils.shadow.ShadowSnackbar +import org.fnives.test.showcase.testutils.shadow.ShadowSnackbarResetTestRule +import org.junit.Assert +import org.junit.rules.TestRule + +object RobolectricSnackbarVerificationTestRule : SnackbarVerificationTestRule, TestRule by ShadowSnackbarResetTestRule() { + + override fun assertIsShownWithText(@StringRes stringResID: Int) { + val latestSnackbar = ShadowSnackbar.latestSnackbar ?: throw IllegalStateException("Snackbar not found") + Assert.assertEquals(latestSnackbar.context.getString(stringResID), ShadowSnackbar.textOfLatestSnackbar) + } + + override fun assertIsNotShown() { + Assert.assertNull(ShadowSnackbar.latestSnackbar) + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt new file mode 100644 index 0000000..66b0f47 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/SpecificTestConfigurationsFactory.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.testutils.configuration + +object SpecificTestConfigurationsFactory : TestConfigurationsFactory { + override fun createMainDispatcherTestRule(): MainDispatcherTestRule = + TestCoroutineMainDispatcherTestRule() + + override fun createServerTypeConfiguration(): ServerTypeConfiguration = + RobolectricServerTypeConfiguration + + override fun createLoginRobotConfiguration(): LoginRobotConfiguration = + RobolectricLoginRobotConfiguration + + override fun createSnackbarVerification(): SnackbarVerificationTestRule = + RobolectricSnackbarVerificationTestRule +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt new file mode 100644 index 0000000..4706794 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/configuration/TestCoroutineMainDispatcherTestRule.kt @@ -0,0 +1,48 @@ +package org.fnives.test.showcase.testutils.configuration + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.fnives.test.showcase.testutils.idling.advanceUntilIdleWithIdlingResources +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class TestCoroutineMainDispatcherTestRule : MainDispatcherTestRule { + + private lateinit var testDispatcher: TestCoroutineDispatcher + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + val dispatcher = TestCoroutineDispatcher() + dispatcher.pauseDispatcher() + Dispatchers.setMain(dispatcher) + testDispatcher = dispatcher + DatabaseInitialization.dispatcher = dispatcher + try { + base.evaluate() + } finally { + Dispatchers.resetMain() + } + } + } + + override fun advanceUntilIdleWithIdlingResources() { + testDispatcher.advanceUntilIdleWithIdlingResources() + } + + override fun advanceUntilIdleOrActivityIsDestroyed() { + advanceUntilIdleWithIdlingResources() + } + + override fun advanceUntilIdle() { + testDispatcher.advanceUntilIdle() + } + + override fun advanceTimeBy(delayInMillis: Long) { + testDispatcher.advanceTimeBy(delayInMillis) + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt new file mode 100644 index 0000000..f5d8193 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbar.kt @@ -0,0 +1,100 @@ +package org.fnives.test.showcase.testutils.shadow + +import android.R +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.StringRes +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.snackbar.ContentViewCallback +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.snackbar.SnackbarContentLayout +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.RealObject +import org.robolectric.shadow.api.Shadow.extract +import java.lang.reflect.Modifier + +@Implements(Snackbar::class) +class ShadowSnackbar { + @RealObject + var snackbar: Snackbar? = null + var text: String? = null + + companion object { + val shadowSnackbars = mutableListOf() + + @Implementation + @JvmStatic + fun make(view: View, text: CharSequence, duration: Int): Snackbar? { + var snackbar: Snackbar? = null + try { + val constructor = Snackbar::class.java.getDeclaredConstructor( + Context::class.java, + ViewGroup::class.java, + View::class.java, + ContentViewCallback::class.java + ) ?: throw IllegalArgumentException("Seems like the constructor was not found!") + if (Modifier.isPrivate(constructor.modifiers)) { + constructor.isAccessible = true + } + val parent = findSuitableParent(view) + val content = LayoutInflater.from(parent.context) + .inflate( + com.google.android.material.R.layout.design_layout_snackbar_include, + parent, + false + ) as SnackbarContentLayout + snackbar = constructor.newInstance(view.context, parent, content, content) + snackbar.setText(text) + snackbar.duration = duration + } catch (e: Exception) { + e.printStackTrace() + throw e + } + shadowOf(snackbar).text = text.toString() + shadowSnackbars.add(shadowOf(snackbar)) + return snackbar + } + + private fun findSuitableParent(view: View): ViewGroup = + when (view) { + is CoordinatorLayout -> view + is FrameLayout -> { + when { + view.id == R.id.content -> view + (view.parent as? View) == null -> view + else -> findSuitableParent(view.parent as View) + } + } + else -> { + when { + (view.parent as? View) == null && view is ViewGroup -> view + (view.parent as? View) == null -> FrameLayout(view.context) + else -> findSuitableParent(view.parent as View) + } + } + } + + @Implementation + @JvmStatic + fun make(view: View, @StringRes resId: Int, duration: Int): Snackbar? = + make(view, view.resources.getText(resId), duration) + + fun shadowOf(bar: Snackbar?): ShadowSnackbar = + extract(bar) + + fun reset() { + shadowSnackbars.clear() + } + + fun shownSnackbarCount(): Int = shadowSnackbars.size + + val textOfLatestSnackbar: String? + get() = shadowSnackbars.lastOrNull()?.text + val latestSnackbar: Snackbar? + get() = shadowSnackbars.lastOrNull()?.snackbar + } +} diff --git a/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt new file mode 100644 index 0000000..3fa4de9 --- /dev/null +++ b/app/src/robolectricTest/java/org/fnives/test/showcase/testutils/shadow/ShadowSnackbarResetTestRule.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.testutils.shadow + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class ShadowSnackbarResetTestRule : TestRule { + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + ShadowSnackbar.reset() + try { + base.evaluate() + } finally { + ShadowSnackbar.reset() + } + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/di/BaseUrlProvider.kt b/app/src/sharedTest/java/org/fnives/test/showcase/di/BaseUrlProvider.kt new file mode 100644 index 0000000..4be8d81 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/di/BaseUrlProvider.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.di + +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory + +object BaseUrlProvider { + + fun get() = BaseUrl(SpecificTestConfigurationsFactory.createServerTypeConfiguration().url) +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt b/app/src/sharedTest/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt new file mode 100644 index 0000000..9a6277b --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/storage/database/DatabaseInitialization.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.storage.database + +import android.content.Context +import androidx.room.Room +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor +import org.fnives.test.showcase.storage.LocalDatabase + +object DatabaseInitialization { + + lateinit var dispatcher: CoroutineDispatcher + + fun create(context: Context): LocalDatabase { + val executor = dispatcher.asExecutor() + return Room.inMemoryDatabaseBuilder(context, LocalDatabase::class.java) + .setTransactionExecutor(executor) + .setQueryExecutor(executor) + .allowMainThreadQueries() + .build() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupTestRule.kt new file mode 100644 index 0000000..df076ab --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/MockServerScenarioSetupTestRule.kt @@ -0,0 +1,36 @@ +package org.fnives.test.showcase.testutils + +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.fnives.test.showcase.testutils.configuration.ServerTypeConfiguration +import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class MockServerScenarioSetupTestRule( + val serverTypeConfiguration: ServerTypeConfiguration = SpecificTestConfigurationsFactory.createServerTypeConfiguration() +) : TestRule { + lateinit var mockServerScenarioSetup: MockServerScenarioSetup + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + before() + try { + base.evaluate() + } finally { + after() + } + } + } + + private fun before() { + mockServerScenarioSetup = MockServerScenarioSetup() + mockServerScenarioSetup.start(serverTypeConfiguration.useHttps) + } + + private fun after() { + mockServerScenarioSetup.stop() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt new file mode 100644 index 0000000..5e65a0f --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/ReloadKoinModulesIfNecessaryTestRule.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.testutils + +import androidx.test.core.app.ApplicationProvider +import org.fnives.test.showcase.TestShowcaseApplication +import org.fnives.test.showcase.di.BaseUrlProvider +import org.fnives.test.showcase.di.createAppModules +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.GlobalContext +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin + +class ReloadKoinModulesIfNecessaryTestRule : TestRule { + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + override fun evaluate() { + if (GlobalContext.getOrNull() == null) { + val application = ApplicationProvider.getApplicationContext() + startKoin { + androidContext(application) + modules(createAppModules(BaseUrlProvider.get())) + } + } + try { + base.evaluate() + } finally { + stopKoin() + } + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/LoginRobotConfiguration.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/LoginRobotConfiguration.kt new file mode 100644 index 0000000..1cc74df --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/LoginRobotConfiguration.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.testutils.configuration + +interface LoginRobotConfiguration { + + val assertLoadingBeforeRequest: Boolean +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/MainDispatcherTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/MainDispatcherTestRule.kt new file mode 100644 index 0000000..6104117 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/MainDispatcherTestRule.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.testutils.configuration + +import org.junit.rules.TestRule + +interface MainDispatcherTestRule : TestRule { + + fun advanceUntilIdleWithIdlingResources() + + fun advanceUntilIdleOrActivityIsDestroyed() + + fun advanceUntilIdle() + + fun advanceTimeBy(delayInMillis: Long) +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/ServerTypeConfiguration.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/ServerTypeConfiguration.kt new file mode 100644 index 0000000..26752aa --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/ServerTypeConfiguration.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.testutils.configuration + +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup + +interface ServerTypeConfiguration { + + val useHttps: Boolean + + val url: String + + fun invoke(mockServerScenarioSetup: MockServerScenarioSetup) +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackBarTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackBarTestRule.kt new file mode 100644 index 0000000..1b2987a --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackBarTestRule.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.testutils.configuration + +import org.junit.rules.TestRule + +interface SnackBarTestRule : TestRule diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationTestRule.kt new file mode 100644 index 0000000..fa89428 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/SnackbarVerificationTestRule.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.testutils.configuration + +import androidx.annotation.StringRes +import org.junit.rules.TestRule + +interface SnackbarVerificationTestRule : TestRule { + + fun assertIsShownWithText(@StringRes stringResID: Int) + + fun assertIsNotShown() +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt new file mode 100644 index 0000000..af3c0d5 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/configuration/TestConfigurationsFactory.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.testutils.configuration + +/** + * Defines the platform specific configurations for Robolectric and AndroidTest. + * + * Each should have an object [SpecificTestConfigurationsFactory] implementing this interface so the SharedTests are + * configured properly. + */ +interface TestConfigurationsFactory { + + fun createMainDispatcherTestRule(): MainDispatcherTestRule + + fun createServerTypeConfiguration(): ServerTypeConfiguration + + fun createLoginRobotConfiguration(): LoginRobotConfiguration + + fun createSnackbarVerification(): SnackbarVerificationTestRule +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/doBlockinglyOnMainThread.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/doBlockinglyOnMainThread.kt new file mode 100644 index 0000000..f9aa3bd --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/doBlockinglyOnMainThread.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.testutils + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking + +fun doBlockinglyOnMainThread(action: () -> Unit) { + if (Looper.myLooper() === Looper.getMainLooper()) { + action() + } else { + val deferred = CompletableDeferred() + Handler(Looper.getMainLooper()).post { + action() + deferred.complete(Unit) + } + runBlocking { deferred.await() } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/CompositeDisposable.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/CompositeDisposable.kt new file mode 100644 index 0000000..a3fbda4 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/CompositeDisposable.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.testutils.idling + +class CompositeDisposable(disposable: List = emptyList()) : Disposable { + + constructor(vararg disposables: Disposable) : this(disposables.toList()) + + private val disposables = disposable.toMutableList() + override val isDisposed: Boolean get() = disposables.all(Disposable::isDisposed) + + fun add(disposable: Disposable) { + disposables.add(disposable) + } + + override fun dispose() { + disposables.forEach { + it.dispose() + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/Disposable.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/Disposable.kt new file mode 100644 index 0000000..56a3e04 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/Disposable.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.testutils.idling + +interface Disposable { + val isDisposed: Boolean + fun dispose() +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/IdlingResourceDisposable.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/IdlingResourceDisposable.kt new file mode 100644 index 0000000..df5e2f3 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/IdlingResourceDisposable.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.testutils.idling + +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.IdlingResource + +internal class IdlingResourceDisposable(private val idlingResource: IdlingResource) : Disposable { + override var isDisposed: Boolean = false + private set + + init { + IdlingRegistry.getInstance().register(idlingResource) + } + + override fun dispose() { + if (isDisposed) return + isDisposed = true + IdlingRegistry.getInstance().unregister(idlingResource) + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronization.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronization.kt new file mode 100644 index 0000000..a6fca71 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/NetworkSynchronization.kt @@ -0,0 +1,33 @@ +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.koin.core.qualifier.StringQualifier +import org.koin.test.KoinTest +import org.koin.test.get + +object NetworkSynchronization : KoinTest { + + @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 = get(StringQualifier(type.qualifier)) + + private fun OkHttpClient.asIdlingResource(name: String): IdlingResource = + OkHttp3IdlingResource.create(name, this) + + enum class OkHttpClientTypes(val qualifier: String) { + SESSION("SESSION-NETWORKING"), SESSIONLESS("SESSIONLESS-NETWORKING") + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt new file mode 100644 index 0000000..896255a --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/idling/awaitIdlingResources.kt @@ -0,0 +1,68 @@ +package org.fnives.test.showcase.testutils.idling + +import androidx.test.espresso.Espresso +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.IdlingResource +import androidx.test.espresso.matcher.ViewMatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestCoroutineDispatcher +import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadFor +import org.fnives.test.showcase.testutils.viewactions.LoopMainThreadUntilIdle + +private val idleScope = CoroutineScope(Dispatchers.IO) + +// workaround, issue with idlingResources is tracked here https://github.com/robolectric/robolectric/issues/4807 +fun anyResourceIdling(): Boolean = !IdlingRegistry.getInstance().resources.all(IdlingResource::isIdleNow) + +fun awaitIdlingResources() { + val idlingRegistry = IdlingRegistry.getInstance() + if (idlingRegistry.resources.all(IdlingResource::isIdleNow)) return + + var isIdle = false + idleScope.launch { + do { + idlingRegistry.resources + .filterNot(IdlingResource::isIdleNow) + .forEach { idlingRegistry -> + idlingRegistry.awaitUntilIdle() + } + } while (!idlingRegistry.resources.all(IdlingResource::isIdleNow)) + isIdle = true + } + while (!isIdle) { + loopMainThreadFor(200L) + } +} + +private suspend fun IdlingResource.awaitUntilIdle() { + // using loop because some times, registerIdleTransitionCallback wasn't called + while (true) { + if (isIdleNow) return + delay(100) + } +} + +fun TestCoroutineDispatcher.advanceUntilIdleWithIdlingResources() { + advanceUntilIdle() // advance until a request is sent + while (anyResourceIdling()) { // check if any request is in progress + awaitIdlingResources() // complete all requests and other idling resources + advanceUntilIdle() // run coroutines after request is finished + } + advanceUntilIdle() +} + +fun loopMainThreadUntilIdleWithIdlingResources() { + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) // advance until a request is sent + while (anyResourceIdling()) { // check if any request is in progress + awaitIdlingResources() // complete all requests and other idling resources + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) // run coroutines after request is finished + } + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadUntilIdle()) +} + +fun loopMainThreadFor(delay: Long) { + Espresso.onView(ViewMatchers.isRoot()).perform(LoopMainThreadFor(delay)) +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt new file mode 100644 index 0000000..c393b4b --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/Robot.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.testutils.robot + +interface Robot { + + fun init() + + fun release() +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt new file mode 100644 index 0000000..e54716b --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/robot/RobotTestRule.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.testutils.robot + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class RobotTestRule(val robot: T) : TestRule { + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + robot.init() + try { + base.evaluate() + } finally { + robot.release() + } + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupLoggedInState.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupLoggedInState.kt new file mode 100644 index 0000000..4e978ee --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/statesetup/SetupLoggedInState.kt @@ -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() + private val loginUseCase get() = get() + private val isUserLoggedInUseCase get() = get() + + 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() } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/LoopMainThreadFor.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/LoopMainThreadFor.kt new file mode 100644 index 0000000..7fe7451 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/LoopMainThreadFor.kt @@ -0,0 +1,27 @@ +package org.fnives.test.showcase.testutils.viewactions + +import android.view.View +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import org.hamcrest.Matcher +import org.hamcrest.Matchers + +class LoopMainThreadFor(private val delayInMillis: Long) : ViewAction { + override fun getConstraints(): Matcher = Matchers.isA(View::class.java) + + override fun getDescription(): String = "loop MainThread for $delayInMillis milliseconds" + + override fun perform(uiController: UiController, view: View?) { + uiController.loopMainThreadForAtLeast(delayInMillis) + } +} + +class LoopMainThreadUntilIdle : ViewAction { + override fun getConstraints(): Matcher = Matchers.isA(View::class.java) + + override fun getDescription(): String = "loop MainThread for until Idle" + + override fun perform(uiController: UiController, view: View?) { + uiController.loopMainThreadUntilIdle() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/PullToRefresh.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/PullToRefresh.kt new file mode 100644 index 0000000..39f9c85 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/PullToRefresh.kt @@ -0,0 +1,44 @@ +package org.fnives.test.showcase.testutils.viewactions + +import android.view.View +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.swiperefreshlayout.widget.listener +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import org.fnives.test.showcase.testutils.doBlockinglyOnMainThread +import org.hamcrest.BaseMatcher +import org.hamcrest.CoreMatchers.isA +import org.hamcrest.Description +import org.hamcrest.Matcher + +// swipe-refresh-layout swipe-down doesn't work, inspired by https://github.com/robolectric/robolectric/issues/5375 +class PullToRefresh : ViewAction { + + override fun getConstraints(): Matcher { + return object : BaseMatcher() { + override fun matches(item: Any): Boolean { + return isA(SwipeRefreshLayout::class.java).matches(item) + } + + override fun describeMismatch(item: Any, mismatchDescription: Description) { + mismatchDescription.appendText("Expected SwipeRefreshLayout or its Descendant, but got other View") + } + + override fun describeTo(description: Description) { + description.appendText("Action SwipeToRefresh to view SwipeRefreshLayout or its descendant") + } + } + } + + override fun getDescription(): String { + return "Perform pull-to-refresh on the SwipeRefreshLayout" + } + + override fun perform(uiController: UiController, view: View) { + val swipeRefreshLayout = view as SwipeRefreshLayout + doBlockinglyOnMainThread { + swipeRefreshLayout.isRefreshing = true + swipeRefreshLayout.listener().onRefresh() + } + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/SwipeRefreshLayoutExtension.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/SwipeRefreshLayoutExtension.kt new file mode 100644 index 0000000..edf9edc --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/SwipeRefreshLayoutExtension.kt @@ -0,0 +1,5 @@ +@file:Suppress("PackageDirectoryMismatch") + +package androidx.swiperefreshlayout.widget + +fun SwipeRefreshLayout.listener() = mListener diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/WithDrawable.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/WithDrawable.kt new file mode 100644 index 0000000..79287c1 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/WithDrawable.kt @@ -0,0 +1,37 @@ +package org.fnives.test.showcase.testutils.viewactions + +import android.content.res.ColorStateList +import android.graphics.PorterDuff +import android.view.View +import android.widget.ImageView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher + +class WithDrawable( + @DrawableRes + private val id: Int, + @ColorRes + private val tint: Int? = null, + private val tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_IN +) : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("ImageView with drawable same as drawable with id $id") + tint?.let { description.appendText(", tint color id: $tint, mode: $tintMode") } + } + + override fun matchesSafely(view: View): Boolean { + val context = view.context + val tintColor = tint?.let { ContextCompat.getColor(view.context, it) } + val expectedBitmap = context.getDrawable(id)?.apply { + if (tintColor != null) { + setTintList(ColorStateList.valueOf(tintColor)) + setTintMode(tintMode) + } + } + return view is ImageView && view.drawable.toBitmap().sameAs(expectedBitmap?.toBitmap()) + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/notIntended.kt b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/notIntended.kt new file mode 100644 index 0000000..a88cf48 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/testutils/viewactions/notIntended.kt @@ -0,0 +1,17 @@ +package org.fnives.test.showcase.testutils.viewactions + +import android.content.Intent +import androidx.test.espresso.intent.Intents.intended +import org.hamcrest.Matcher +import org.hamcrest.StringDescription + +fun notIntended(matcher: Matcher) { + try { + intended(matcher) + } catch (assertionError: AssertionError) { + return + } + val description = StringDescription() + matcher.describeMismatch(null, description) + throw IllegalStateException("Navigate to intent found matching $description") +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt new file mode 100644 index 0000000..4f6dbfa --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/HomeRobot.kt @@ -0,0 +1,95 @@ +package org.fnives.test.showcase.ui.home + +import android.app.Instrumentation +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasChildCount +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withChild +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withParent +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.fnives.test.showcase.R +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.testutils.robot.Robot +import org.fnives.test.showcase.testutils.viewactions.PullToRefresh +import org.fnives.test.showcase.testutils.viewactions.WithDrawable +import org.fnives.test.showcase.testutils.viewactions.notIntended +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.hamcrest.Matchers.allOf + +class HomeRobot : Robot { + + override fun init() { + Intents.init() + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(0, null)) + } + + override fun release() { + Intents.release() + } + + fun assertNavigatedToAuth() = apply { + Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun assertDidNotNavigateToAuth() = apply { + notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun clickSignOut() = apply { + Espresso.onView(withId(R.id.logout_cta)).perform(click()) + } + + fun assertContainsItem(item: FavouriteContent) = apply { + val isFavouriteResourceId = if (item.isFavourite) { + R.drawable.favorite_24 + } else { + R.drawable.favorite_border_24 + } + Espresso.onView( + allOf( + withChild(allOf(withText(item.content.title), withId(R.id.title))), + withChild(allOf(withText(item.content.description), withId(R.id.description))), + withChild(allOf(withId(R.id.favourite_cta), WithDrawable(isFavouriteResourceId))) + ) + ) + .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + } + + fun clickOnContentItem(item: Content) = apply { + Espresso.onView( + allOf( + withId(R.id.favourite_cta), + withParent( + allOf( + withChild(allOf(withText(item.title), withId(R.id.title))), + withChild(allOf(withText(item.description), withId(R.id.description))) + ) + ) + ) + ) + .perform(click()) + } + + fun swipeRefresh() = apply { + Espresso.onView(withId(R.id.swipe_refresh_layout)).perform(PullToRefresh()) + } + + fun assertContainsNoItems() = apply { + Espresso.onView(withId(R.id.recycler)) + .check(matches(hasChildCount(0))) + } + + fun assertContainsError() = apply { + Espresso.onView(withId(R.id.error_message)) + .check(matches(allOf(isDisplayed(), withText(R.string.something_went_wrong)))) + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityTest.kt new file mode 100644 index 0000000..b2c9572 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/home/MainActivityTest.kt @@ -0,0 +1,239 @@ +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 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.ReloadKoinModulesIfNecessaryTestRule +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 org.koin.test.KoinTest + +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +class MainActivityTest : KoinTest { + + private lateinit var activityScenario: ActivityScenario + + @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 reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule() + + private lateinit var disposable: Disposable + + @Before + fun setUp() { + SpecificTestConfigurationsFactory.createServerTypeConfiguration() + .invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + + 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(MainActivity::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(MainActivity::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(MainActivity::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(MainActivity::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(MainActivity::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(MainActivity::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(MainActivity::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(MainActivity::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(MainActivity::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(MainActivity::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(MainActivity::class.java) + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + + homeRobot.assertNavigatedToAuth() + Assert.assertEquals(false, SetupLoggedInState.isLoggedIn()) + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityTest.kt new file mode 100644 index 0000000..e971de2 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/AuthActivityTest.kt @@ -0,0 +1,159 @@ +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 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.ReloadKoinModulesIfNecessaryTestRule +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.AuthActivity +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 + +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +class AuthActivityTest : KoinTest { + + private lateinit var activityScenario: ActivityScenario + + @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 reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule() + + private lateinit var disposable: Disposable + + @Before + fun setUp() { + SpecificTestConfigurationsFactory.createServerTypeConfiguration() + .invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + 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(AuthActivity::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(AuthActivity::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(AuthActivity::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(AuthActivity::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(AuthActivity::class.java) + loginRobot + .setUsername("alma") + .setPassword("banan") + .assertUsername("alma") + .assertPassword("banan") + .clickOnLogin() + .assertLoadingBeforeRequests() + + mainDispatcherTestRule.advanceUntilIdleWithIdlingResources() + loginRobot.assertErrorIsShown(R.string.something_went_wrong) + .assertNotNavigatedToHome() + .assertNotLoading() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt new file mode 100644 index 0000000..f291677 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/login/LoginRobot.kt @@ -0,0 +1,100 @@ +package org.fnives.test.showcase.ui.login + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.annotation.StringRes +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.Intents.intending +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.fnives.test.showcase.R +import org.fnives.test.showcase.testutils.configuration.LoginRobotConfiguration +import org.fnives.test.showcase.testutils.configuration.SnackbarVerificationTestRule +import org.fnives.test.showcase.testutils.configuration.SpecificTestConfigurationsFactory +import org.fnives.test.showcase.testutils.configuration.TestConfigurationsFactory +import org.fnives.test.showcase.testutils.robot.Robot +import org.fnives.test.showcase.testutils.viewactions.notIntended +import org.fnives.test.showcase.ui.home.MainActivity +import org.hamcrest.core.IsNot.not + +class LoginRobot( + private val loginRobotConfiguration: LoginRobotConfiguration, + private val snackbarVerificationTestRule: SnackbarVerificationTestRule +) : Robot { + + constructor(testConfigurationsFactory: TestConfigurationsFactory = SpecificTestConfigurationsFactory) : + this( + loginRobotConfiguration = testConfigurationsFactory.createLoginRobotConfiguration(), + snackbarVerificationTestRule = testConfigurationsFactory.createSnackbarVerification() + ) + + override fun init() { + Intents.init() + intending(hasComponent(MainActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + } + + override fun release() { + Intents.release() + } + + fun setUsername(username: String): LoginRobot = apply { + onView(withId(R.id.user_edit_text)) + .perform(ViewActions.replaceText(username), ViewActions.closeSoftKeyboard()) + } + + fun setPassword(password: String): LoginRobot = apply { + onView(withId(R.id.password_edit_text)) + .perform(ViewActions.replaceText(password), ViewActions.closeSoftKeyboard()) + } + + fun clickOnLogin() = apply { + onView(withId(R.id.login_cta)) + .perform(ViewActions.click()) + } + + fun assertPassword(password: String) = apply { + onView(withId((R.id.password_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(password))) + } + + fun assertUsername(username: String) = apply { + onView(withId((R.id.user_edit_text))) + .check(ViewAssertions.matches(ViewMatchers.withText(username))) + } + + fun assertErrorIsShown(@StringRes stringResID: Int) = apply { + snackbarVerificationTestRule.assertIsShownWithText(stringResID) + } + + fun assertLoadingBeforeRequests() = apply { + if (loginRobotConfiguration.assertLoadingBeforeRequest) { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(isDisplayed())) + } + } + + fun assertNotLoading() = apply { + onView(withId(R.id.loading_indicator)) + .check(ViewAssertions.matches(not(isDisplayed()))) + } + + fun assertErrorIsNotShown() = apply { + snackbarVerificationTestRule.assertIsNotShown() + } + + fun assertNavigatedToHome() = apply { + intended(hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertNotNavigatedToHome() = apply { + notIntended(hasComponent(MainActivity::class.java.canonicalName)) + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt new file mode 100644 index 0000000..ea8c92b --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashActivityTest.kt @@ -0,0 +1,89 @@ +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 org.fnives.test.showcase.testutils.MockServerScenarioSetupTestRule +import org.fnives.test.showcase.testutils.ReloadKoinModulesIfNecessaryTestRule +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 + +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +class SplashActivityTest : KoinTest { + + private lateinit var activityScenario: ActivityScenario + + 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 reloadKoinModulesIfNecessaryTestRule = ReloadKoinModulesIfNecessaryTestRule() + + lateinit var disposable: Disposable + + @Before + fun setUp() { + SpecificTestConfigurationsFactory.createServerTypeConfiguration() + .invoke(mockServerScenarioSetupTestRule.mockServerScenarioSetup) + 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(SplashActivity::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(SplashActivity::class.java) + + mainDispatcherTestRule.advanceTimeBy(500) + + splashRobot.assertAuthIsStarted() + .assertHomeIsNotStarted() + } +} diff --git a/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt new file mode 100644 index 0000000..ae89da1 --- /dev/null +++ b/app/src/sharedTest/java/org/fnives/test/showcase/ui/splash/SplashRobot.kt @@ -0,0 +1,40 @@ +package org.fnives.test.showcase.ui.splash + +import android.app.Instrumentation +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import org.fnives.test.showcase.testutils.robot.Robot +import org.fnives.test.showcase.testutils.viewactions.notIntended +import org.fnives.test.showcase.ui.auth.AuthActivity +import org.fnives.test.showcase.ui.home.MainActivity + +class SplashRobot : Robot { + + override fun init() { + Intents.init() + Intents.intending(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(0, null)) + Intents.intending(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + .respondWith(Instrumentation.ActivityResult(0, null)) + } + + override fun release() { + Intents.release() + } + + fun assertHomeIsStarted() = apply { + Intents.intended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertHomeIsNotStarted() = apply { + notIntended(IntentMatchers.hasComponent(MainActivity::class.java.canonicalName)) + } + + fun assertAuthIsStarted() = apply { + Intents.intended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } + + fun assertAuthIsNotStarted() = apply { + notIntended(IntentMatchers.hasComponent(AuthActivity::class.java.canonicalName)) + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/di/DITest.kt b/app/src/test/java/org/fnives/test/showcase/di/DITest.kt new file mode 100644 index 0000000..1a88d3d --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/di/DITest.kt @@ -0,0 +1,63 @@ +package org.fnives.test.showcase.di + +import android.content.Context +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.fnives.test.showcase.ui.auth.AuthViewModel +import org.fnives.test.showcase.ui.home.MainViewModel +import org.fnives.test.showcase.ui.splash.SplashViewModel +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.check.checkModules +import org.koin.test.inject +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@ExtendWith(TestMainDispatcher::class) +class DITest : KoinTest { + + private val authViewModel by inject() + private val mainViewModel by inject() + private val splashViewModel by inject() + + @BeforeEach + fun setUp() { + TestMainDispatcher.testDispatcher.pauseDispatcher() + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun verifyStaticModules() { + val mockContext = mock() + whenever(mockContext.getSharedPreferences(anyOrNull(), anyOrNull())).doReturn(mock()) + checkModules { + androidContext(mockContext) + modules(createAppModules(BaseUrl("https://a.com/"))) + } + } + + @Test + fun verifyViewModelModules() { + val mockContext = mock() + whenever(mockContext.getSharedPreferences(anyOrNull(), anyOrNull())).doReturn(mock()) + startKoin { + androidContext(mockContext) + modules(createAppModules(BaseUrl("https://a.com/"))) + } + authViewModel + mainViewModel + splashViewModel + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/testutils/InstantExecutorExtension.kt b/app/src/test/java/org/fnives/test/showcase/testutils/InstantExecutorExtension.kt new file mode 100644 index 0000000..bfd0594 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/testutils/InstantExecutorExtension.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.testutils + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance() + .setDelegate(object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }) + } + + override fun afterEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate(null) + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt b/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt new file mode 100644 index 0000000..3915734 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/testutils/TestMainDispatcher.kt @@ -0,0 +1,31 @@ +package org.fnives.test.showcase.testutils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.fnives.test.showcase.storage.database.DatabaseInitialization +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class TestMainDispatcher : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + val testDispatcher = TestCoroutineDispatcher() + privateTestDispatcher = testDispatcher + DatabaseInitialization.dispatcher = testDispatcher + Dispatchers.setMain(testDispatcher) + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + privateTestDispatcher = null + } + + companion object { + private var privateTestDispatcher: TestCoroutineDispatcher? = null + val testDispatcher: TestCoroutineDispatcher + get() = privateTestDispatcher ?: throw IllegalStateException("TestMainDispatcher is in afterEach State") + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt new file mode 100644 index 0000000..242a8e7 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/auth/AuthViewModelTest.kt @@ -0,0 +1,190 @@ +package org.fnives.test.showcase.ui.auth + +import com.jraska.livedata.test +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.core.login.LoginUseCase +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.testutils.InstantExecutorExtension +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.fnives.test.showcase.ui.shared.Event +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import java.util.stream.Stream + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +internal class AuthViewModelTest { + + private lateinit var sut: AuthViewModel + private lateinit var mockLoginUseCase: LoginUseCase + private val testDispatcher get() = TestMainDispatcher.testDispatcher + + @BeforeEach + fun setUp() { + mockLoginUseCase = mock() + testDispatcher.pauseDispatcher() + sut = AuthViewModel(mockLoginUseCase) + } + + @Test + fun GIVEN_initialized_viewModel_WHEN_observed_THEN_loading_false_other_fields_are_empty() { + testDispatcher.resumeDispatcher() + sut.username.test().assertNoValue() + sut.password.test().assertNoValue() + sut.loading.test().assertValue(false) + sut.error.test().assertNoValue() + sut.navigateToHome.test().assertNoValue() + } + + @Test + fun GIVEN_password_text_WHEN_onPasswordChanged_is_called_THEN_password_livedata_is_updated() { + testDispatcher.resumeDispatcher() + val passwordTestObserver = sut.password.test() + + sut.onPasswordChanged("a") + sut.onPasswordChanged("al") + + passwordTestObserver.assertValueHistory("a", "al") + sut.username.test().assertNoValue() + sut.loading.test().assertValue(false) + sut.error.test().assertNoValue() + sut.navigateToHome.test().assertNoValue() + } + + @Test + fun GIVEN_username_text_WHEN_onUsernameChanged_is_called_THEN_username_livedata_is_updated() { + testDispatcher.resumeDispatcher() + val usernameTestObserver = sut.username.test() + + sut.onUsernameChanged("a") + sut.onUsernameChanged("al") + + usernameTestObserver.assertValueHistory("a", "al") + sut.password.test().assertNoValue() + sut.loading.test().assertValue(false) + sut.error.test().assertNoValue() + sut.navigateToHome.test().assertNoValue() + } + + @Test + fun GIVEN_no_password_or_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase() { + val loadingTestObserver = sut.loading.test() + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + loadingTestObserver.assertValueHistory(false, true, false) + runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @Test + fun WHEN_login_is_Called_twise_THEN_use_case_is_only_called_once() { + runBlocking { whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) } + + sut.onLogin() + sut.onLogin() + testDispatcher.advanceUntilIdle() + + runBlocking { verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("", "")) } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @Test + fun GIVEN_password_and_username_WHEN_login_is_Called_THEN_empty_credentials_are_used_in_usecase() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + sut.onPasswordChanged("pass") + sut.onUsernameChanged("usr") + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + runBlocking { + verify(mockLoginUseCase, times(1)).invoke(LoginCredentials("usr", "pass")) + } + verifyNoMoreInteractions(mockLoginUseCase) + } + + @Test + fun GIVEN_answer_error_WHEN_login_called_THEN_error_is_shown() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Error(Throwable())) + } + val loadingObserver = sut.loading.test() + val errorObserver = sut.error.test() + val navigateToHomeObserver = sut.navigateToHome.test() + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + loadingObserver.assertValueHistory(false, true, false) + errorObserver.assertValueHistory(Event(AuthViewModel.ErrorType.GENERAL_NETWORK_ERROR)) + navigateToHomeObserver.assertNoValue() + } + + @MethodSource("loginErrorStatusesArguments") + @ParameterizedTest(name = "GIVEN_answer_success_loginStatus_{0}_WHEN_login_called_THEN_error_{1}_is_shown") + fun GIVEN_answer_success_invalid_loginStatus_WHEN_login_called_THEN_error_is_shown( + loginStatus: LoginStatus, + errorType: AuthViewModel.ErrorType + ) { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(loginStatus)) + } + val loadingObserver = sut.loading.test() + val errorObserver = sut.error.test() + val navigateToHomeObserver = sut.navigateToHome.test() + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + loadingObserver.assertValueHistory(false, true, false) + errorObserver.assertValueHistory(Event(errorType)) + navigateToHomeObserver.assertNoValue() + } + + @Test + fun GIVEN_answer_success_login_status_success_WHEN_login_called_THEN_navigation_event_is_sent() { + runBlocking { + whenever(mockLoginUseCase.invoke(anyOrNull())).doReturn(Answer.Success(LoginStatus.SUCCESS)) + } + val loadingObserver = sut.loading.test() + val errorObserver = sut.error.test() + val navigateToHomeObserver = sut.navigateToHome.test() + + sut.onLogin() + testDispatcher.advanceUntilIdle() + + loadingObserver.assertValueHistory(false, true, false) + errorObserver.assertNoValue() + navigateToHomeObserver.assertValueHistory(Event(Unit)) + } + + companion object { + + @JvmStatic + fun loginErrorStatusesArguments(): Stream = Stream.of( + Arguments.of(LoginStatus.INVALID_CREDENTIALS, AuthViewModel.ErrorType.INVALID_CREDENTIALS), + Arguments.of(LoginStatus.INVALID_PASSWORD, AuthViewModel.ErrorType.UNSUPPORTED_PASSWORD), + Arguments.of(LoginStatus.INVALID_USERNAME, AuthViewModel.ErrorType.UNSUPPORTED_USERNAME) + ) + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt new file mode 100644 index 0000000..e6f7eb7 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/home/MainViewModelTest.kt @@ -0,0 +1,243 @@ +package org.fnives.test.showcase.ui.home + +import com.jraska.livedata.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.core.content.FetchContentUseCase +import org.fnives.test.showcase.core.content.GetAllContentUseCase +import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase +import org.fnives.test.showcase.core.login.LogoutUseCase +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.testutils.InstantExecutorExtension +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +internal class MainViewModelTest { + + private lateinit var sut: MainViewModel + private lateinit var mockGetAllContentUseCase: GetAllContentUseCase + private lateinit var mockLogoutUseCase: LogoutUseCase + private lateinit var mockFetchContentUseCase: FetchContentUseCase + private lateinit var mockAddContentToFavouriteUseCase: AddContentToFavouriteUseCase + private lateinit var mockRemoveContentFromFavouritesUseCase: RemoveContentFromFavouritesUseCase + private val testDispatcher get() = TestMainDispatcher.testDispatcher + + @BeforeEach + fun setUp() { + mockGetAllContentUseCase = mock() + mockLogoutUseCase = mock() + mockFetchContentUseCase = mock() + mockAddContentToFavouriteUseCase = mock() + mockRemoveContentFromFavouritesUseCase = mock() + testDispatcher.pauseDispatcher() + sut = MainViewModel( + getAllContentUseCase = mockGetAllContentUseCase, + logoutUseCase = mockLogoutUseCase, + fetchContentUseCase = mockFetchContentUseCase, + addContentToFavouriteUseCase = mockAddContentToFavouriteUseCase, + removeContentFromFavouritesUseCase = mockRemoveContentFromFavouritesUseCase + ) + } + + @Test + fun WHEN_initialization_THEN_error_false_other_states_empty() { + sut.errorMessage.test().assertValue(false) + sut.content.test().assertNoValue() + sut.loading.test().assertNoValue() + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_initialized_viewModel_WHEN_loading_is_returned_THEN_loading_is_shown() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.errorMessage.test().assertValue(false) + sut.content.test().assertNoValue() + sut.loading.test().assertValue(true) + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_loading_then_data_WHEN_observing_content_THEN_proper_states_are_shown() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading(), Resource.Success(emptyList()))) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, false, false) + contentTestObserver.assertValueHistory(listOf()) + loadingTestObserver.assertValueHistory(true, false) + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_loading_then_error_WHEN_observing_content_THEN_proper_states_are_shown() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading(), Resource.Error(Throwable()))) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, false, true) + contentTestObserver.assertValueHistory(emptyList()) + loadingTestObserver.assertValueHistory(true, false) + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_loading_then_error_then_loading_then_data_WHEN_observing_content_THEN_proper_states_are_shown() { + val content = listOf( + FavouriteContent(Content(ContentId(""), "", "", ImageUrl("")), false) + ) + whenever(mockGetAllContentUseCase.get()).doReturn( + flowOf( + Resource.Loading(), + Resource.Error(Throwable()), + Resource.Loading(), + Resource.Success(content) + ) + ) + val errorMessageTestObserver = sut.errorMessage.test() + val contentTestObserver = sut.content.test() + val loadingTestObserver = sut.loading.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + errorMessageTestObserver.assertValueHistory(false, false, true, false, false) + contentTestObserver.assertValueHistory(emptyList(), content) + loadingTestObserver.assertValueHistory(true, false, true, false) + sut.navigateToAuth.test().assertNoValue() + } + + @Test + fun GIVEN_loading_viewModel_WHEN_refreshing_THEN_usecase_is_not_called() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onRefresh() + testDispatcher.advanceUntilIdle() + + verifyZeroInteractions(mockFetchContentUseCase) + } + + @Test + fun GIVEN_non_loading_viewModel_WHEN_refreshing_THEN_usecase_is_called() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onRefresh() + testDispatcher.advanceUntilIdle() + + verify(mockFetchContentUseCase, times(1)).invoke() + verifyNoMoreInteractions(mockFetchContentUseCase) + } + + @Test + fun GIVEN_loading_viewModel_WHEN_loging_out_THEN_usecase_is_called() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Loading())) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onLogout() + testDispatcher.advanceUntilIdle() + + runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } + verifyNoMoreInteractions(mockLogoutUseCase) + } + + @Test + fun GIVEN_non_loading_viewModel_WHEN_loging_out_THEN_usecase_is_called() { + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf()) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onLogout() + testDispatcher.advanceUntilIdle() + + runBlocking { verify(mockLogoutUseCase, times(1)).invoke() } + verifyNoMoreInteractions(mockLogoutUseCase) + } + + @Test + fun GIVEN_success_content_list_viewModel_WHEN_toggling_a_nonexistent_contentId_THEN_nothing_happens() { + val contents = listOf( + FavouriteContent(Content(ContentId("a"), "", "", ImageUrl("")), false), + FavouriteContent(Content(ContentId("b"), "", "", ImageUrl("")), true) + ) + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("c")) + testDispatcher.advanceUntilIdle() + + verifyZeroInteractions(mockRemoveContentFromFavouritesUseCase) + verifyZeroInteractions(mockAddContentToFavouriteUseCase) + } + + @Test + fun GIVEN_success_content_list_viewModel_WHEN_toggling_a_favourite_contentId_THEN_remove_favourite_usecase_is_called() { + val contents = listOf( + FavouriteContent(Content(ContentId("a"), "", "", ImageUrl("")), false), + FavouriteContent(Content(ContentId("b"), "", "", ImageUrl("")), true) + ) + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("b")) + testDispatcher.advanceUntilIdle() + + runBlocking { verify(mockRemoveContentFromFavouritesUseCase, times(1)).invoke(ContentId("b")) } + verifyNoMoreInteractions(mockRemoveContentFromFavouritesUseCase) + verifyZeroInteractions(mockAddContentToFavouriteUseCase) + } + + @Test + fun GIVEN_success_content_list_viewModel_WHEN_toggling_a_not_favourite_contentId_THEN_add_favourite_usecase_is_called() { + val contents = listOf( + FavouriteContent(Content(ContentId("a"), "", "", ImageUrl("")), false), + FavouriteContent(Content(ContentId("b"), "", "", ImageUrl("")), true) + ) + whenever(mockGetAllContentUseCase.get()).doReturn(flowOf(Resource.Success(contents))) + sut.content.test() + testDispatcher.resumeDispatcher() + testDispatcher.advanceUntilIdle() + + sut.onFavouriteToggleClicked(ContentId("a")) + testDispatcher.advanceUntilIdle() + + verifyZeroInteractions(mockRemoveContentFromFavouritesUseCase) + runBlocking { verify(mockAddContentToFavouriteUseCase, times(1)).invoke(ContentId("a")) } + verifyNoMoreInteractions(mockAddContentToFavouriteUseCase) + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/ui/shared/EventTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/shared/EventTest.kt new file mode 100644 index 0000000..1c1043b --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/shared/EventTest.kt @@ -0,0 +1,48 @@ +package org.fnives.test.showcase.ui.shared + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +@Suppress("TestFunctionName") +internal class EventTest { + + @Test + fun GIVEN_event_WHEN_consumed_is_called_THEN_value_is_returned() { + val expected = "a" + + val actual = Event("a").consume() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_consumed_event_WHEN_consumed_is_called_THEN_null_is_returned() { + val expected: String? = null + val event = Event("a") + event.consume() + + val actual = event.consume() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_event_WHEN_peek_is_called_THEN_value_is_returned() { + val expected = "a" + + val actual = Event("a").peek() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_consumed_event_WHEN_peek_is_called_THEN_value_is_returned() { + val expected = "a" + val event = Event("a") + event.consume() + + val actual = event.peek() + + Assertions.assertEquals(expected, actual) + } +} diff --git a/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt b/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt new file mode 100644 index 0000000..dfcb5e9 --- /dev/null +++ b/app/src/test/java/org/fnives/test/showcase/ui/splash/SplashViewModelTest.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.ui.splash + +import com.jraska.livedata.test +import org.fnives.test.showcase.core.login.IsUserLoggedInUseCase +import org.fnives.test.showcase.testutils.InstantExecutorExtension +import org.fnives.test.showcase.testutils.TestMainDispatcher +import org.fnives.test.showcase.ui.shared.Event +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +@ExtendWith(InstantExecutorExtension::class, TestMainDispatcher::class) +internal class SplashViewModelTest { + + private lateinit var mockIsUserLoggedInUseCase: IsUserLoggedInUseCase + private lateinit var sut: SplashViewModel + private val testCoroutineDispatcher get() = TestMainDispatcher.testDispatcher + + @BeforeEach + fun setUp() { + mockIsUserLoggedInUseCase = mock() + sut = SplashViewModel(mockIsUserLoggedInUseCase) + } + + @Test + fun GIVEN_not_logged_in_user_WHEN_splash_started_THEN_after_half_a_second_navigated_to_authentication() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + + testCoroutineDispatcher.advanceTimeBy(500) + + sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.AUTHENTICATION)) + } + + @Test + fun GIVEN_logged_in_user_WHEN_splash_started_THEN_after_half_a_second_navigated_to_home() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(true) + + testCoroutineDispatcher.advanceTimeBy(500) + + sut.navigateTo.test().assertValue(Event(SplashViewModel.NavigateTo.HOME)) + } + + @Test + fun GIVEN_not_logged_in_user_WHEN_splash_started_THEN_before_half_a_second_no_event_is_sent() { + whenever(mockIsUserLoggedInUseCase.invoke()).doReturn(false) + + testCoroutineDispatcher.advanceTimeBy(100) + + sut.navigateTo.test().assertNoValue() + } +} diff --git a/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..0b813b6 --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +sdk=28 +shadows=org.fnives.test.showcase.testutils.shadow.ShadowSnackbar diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..f6cdc86 --- /dev/null +++ b/build.gradle @@ -0,0 +1,63 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext.kotlin_version = "1.4.32" + ext.detekt_version = "1.16.0" + repositories { + google() + maven { url "https://plugins.gradle.org/m2/" } + jcenter() + } + dependencies { + classpath "com.android.tools.build:gradle:4.1.3" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jlleitschuh.gradle:ktlint-gradle:10.0.0" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +//apply plugin: "io.gitlab.arturbosch.detekt" version "$detekt_version" +plugins { + id "io.gitlab.arturbosch.detekt" version "$detekt_version" +} +detekt { + toolVersion = "$detekt_version" + + input = files( + "$projectDir/app/src/main/java", + "$projectDir/core/src/main/java", + "$projectDir/mockserver/src/main/java", + "$projectDir/model/src/main/java", + "$projectDir/network/src/main/java" + ) + config = files("$projectDir/detekt/detekt.yml") + baseline = file("$projectDir/detekt/baseline.xml") + reports { + txt { + enabled = true + destination = file("build/reports/detekt.txt") + } + html { + enabled = true + destination = file("build/reports/detekt.html") + } + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +subprojects { + apply plugin: "org.jlleitschuh.gradle.ktlint" +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + +apply from: 'gradlescripts/versions.gradle' \ No newline at end of file diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 0000000..1e5acf0 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'java-library' + id 'kotlin' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +test { + useJUnitPlatform() + testLogging { + events 'started', 'passed', 'skipped', 'failed' + exceptionFormat "full" + showStandardStreams true + } +} + +dependencies { + api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + + api project(":model") + implementation project(":network") + + testImplementation "org.koin:koin-test:$koin_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version" + testImplementation "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" +} \ No newline at end of file diff --git a/core/src/main/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCase.kt new file mode 100644 index 0000000..c5ea010 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCase.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.core.content + +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId + +class AddContentToFavouriteUseCase internal constructor( + private val favouriteContentLocalStorage: FavouriteContentLocalStorage +) { + + suspend fun invoke(contentId: ContentId) = + favouriteContentLocalStorage.markAsFavourite(contentId) +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/content/ContentRepository.kt b/core/src/main/java/org/fnives/test/showcase/core/content/ContentRepository.kt new file mode 100644 index 0000000..519110d --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/ContentRepository.kt @@ -0,0 +1,35 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.fnives.test.showcase.core.shared.Optional +import org.fnives.test.showcase.core.shared.mapIntoResource +import org.fnives.test.showcase.core.shared.wrapIntoAnswer +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.network.content.ContentRemoteSource + +internal class ContentRepository(private val contentRemoteSource: ContentRemoteSource) { + + private val mutableContentFlow = MutableStateFlow(Optional>(null)) + private val requestFlow: Flow>> = flow { + emit(Resource.Loading()) + val response = wrapIntoAnswer { contentRemoteSource.get() }.mapIntoResource() + if (response is Resource.Success) { + mutableContentFlow.value = Optional(response.data) + } + emit(response) + } + val contents: Flow>> = mutableContentFlow.flatMapLatest { + if (it.item != null) flowOf(Resource.Success(it.item)) else requestFlow + } + .distinctUntilChanged() + + fun fetch() { + mutableContentFlow.value = Optional(null) + } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/content/FetchContentUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/content/FetchContentUseCase.kt new file mode 100644 index 0000000..90c7a5b --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/FetchContentUseCase.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.core.content + +class FetchContentUseCase internal constructor(private val contentRepository: ContentRepository) { + + fun invoke() = contentRepository.fetch() +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt new file mode 100644 index 0000000..d078d68 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/GetAllContentUseCase.kt @@ -0,0 +1,33 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.shared.Resource + +class GetAllContentUseCase internal constructor( + private val contentRepository: ContentRepository, + private val favouriteContentLocalStorage: FavouriteContentLocalStorage +) { + + fun get(): Flow>> = + contentRepository.contents.combine(favouriteContentLocalStorage.observeFavourites(), ::combineContentWithFavourites) + + companion object { + private fun combineContentWithFavourites( + contentResource: Resource>, + favouriteContents: List + ): Resource> = + when (contentResource) { + is Resource.Error -> Resource.Error(contentResource.error) + is Resource.Loading -> Resource.Loading() + is Resource.Success -> Resource.Success(combineContentWithFavourites(contentResource.data, favouriteContents)) + } + + private fun combineContentWithFavourites(content: List, favourite: List): List = + content.map { FavouriteContent(content = it, isFavourite = favourite.contains(it.id)) } + } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCase.kt new file mode 100644 index 0000000..494af3e --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCase.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.core.content + +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId + +class RemoveContentFromFavouritesUseCase internal constructor( + private val favouriteContentLocalStorage: FavouriteContentLocalStorage +) { + + suspend fun invoke(contentId: ContentId) { + favouriteContentLocalStorage.deleteAsFavourite(contentId) + } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/di/createCoreModule.kt b/core/src/main/java/org/fnives/test/showcase/core/di/createCoreModule.kt new file mode 100644 index 0000000..a2b18d1 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/di/createCoreModule.kt @@ -0,0 +1,60 @@ +package org.fnives.test.showcase.core.di + +import org.fnives.test.showcase.core.content.AddContentToFavouriteUseCase +import org.fnives.test.showcase.core.content.ContentRepository +import org.fnives.test.showcase.core.content.FetchContentUseCase +import org.fnives.test.showcase.core.content.GetAllContentUseCase +import org.fnives.test.showcase.core.content.RemoveContentFromFavouritesUseCase +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.core.session.SessionExpirationAdapter +import org.fnives.test.showcase.core.session.SessionExpirationListener +import org.fnives.test.showcase.core.storage.NetworkSessionLocalStorageAdapter +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.di.createNetworkModules +import org.koin.core.module.Module +import org.koin.core.scope.Scope +import org.koin.dsl.module + +fun createCoreModule( + baseUrl: BaseUrl, + enableNetworkLogging: Boolean, + userDataLocalStorageProvider: Scope.() -> UserDataLocalStorage, + sessionExpirationListenerProvider: Scope.() -> SessionExpirationListener, + favouriteContentLocalStorageProvider: Scope.() -> FavouriteContentLocalStorage +): Sequence = + createNetworkModules( + baseUrl = baseUrl, + enableLogging = enableNetworkLogging, + networkSessionLocalStorageProvider = { get() }, + networkSessionExpirationListenerProvider = { SessionExpirationAdapter(sessionExpirationListenerProvider()) } + ) + .plus(useCaseModule()) + .plus(storageModule(userDataLocalStorageProvider, favouriteContentLocalStorageProvider)) + .plus(repositoryModule()) + +fun repositoryModule() = module { + single(override = true) { ContentRepository(get()) } +} + +fun useCaseModule() = module { + factory { LoginUseCase(get(), get()) } + factory { LogoutUseCase(get()) } + factory { GetAllContentUseCase(get(), get()) } + factory { AddContentToFavouriteUseCase(get()) } + factory { RemoveContentFromFavouritesUseCase(get()) } + factory { IsUserLoggedInUseCase(get()) } + factory { FetchContentUseCase(get()) } +} + +fun storageModule( + userDataLocalStorageProvider: Scope.() -> UserDataLocalStorage, + favouriteContentLocalStorageProvider: Scope.() -> FavouriteContentLocalStorage +) = module { + single { userDataLocalStorageProvider() } + single { favouriteContentLocalStorageProvider() } + factory { NetworkSessionLocalStorageAdapter(get()) } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCase.kt new file mode 100644 index 0000000..fd0539a --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCase.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.core.login + +import org.fnives.test.showcase.core.storage.UserDataLocalStorage + +class IsUserLoggedInUseCase(private val userDataLocalStorage: UserDataLocalStorage) { + + fun invoke(): Boolean = userDataLocalStorage.session != null +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/login/LoginUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/login/LoginUseCase.kt new file mode 100644 index 0000000..2a91780 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/login/LoginUseCase.kt @@ -0,0 +1,30 @@ +package org.fnives.test.showcase.core.login + +import org.fnives.test.showcase.core.shared.wrapIntoAnswer +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.network.auth.LoginRemoteSource +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses + +class LoginUseCase internal constructor( + private val loginRemoteSource: LoginRemoteSource, + private val userDataLocalStorage: UserDataLocalStorage +) { + + suspend fun invoke(credentials: LoginCredentials): Answer { + if (credentials.username.isBlank()) return Answer.Success(LoginStatus.INVALID_USERNAME) + if (credentials.password.isBlank()) return Answer.Success(LoginStatus.INVALID_PASSWORD) + + return wrapIntoAnswer { + when (val response = loginRemoteSource.login(credentials)) { + LoginStatusResponses.InvalidCredentials -> LoginStatus.INVALID_CREDENTIALS + is LoginStatusResponses.Success -> { + userDataLocalStorage.session = response.session + LoginStatus.SUCCESS + } + } + } + } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/login/LogoutUseCase.kt b/core/src/main/java/org/fnives/test/showcase/core/login/LogoutUseCase.kt new file mode 100644 index 0000000..158e7e0 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/login/LogoutUseCase.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.core.login + +import org.fnives.test.showcase.core.di.repositoryModule +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.koin.core.context.loadKoinModules + +class LogoutUseCase(private val storage: UserDataLocalStorage) { + + suspend fun invoke() { + loadKoinModules(repositoryModule()) + storage.session = null + } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationAdapter.kt b/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationAdapter.kt new file mode 100644 index 0000000..1947635 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationAdapter.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.core.session + +import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener + +internal class SessionExpirationAdapter( + private val sessionExpirationListener: SessionExpirationListener +) : + NetworkSessionExpirationListener { + + override fun onSessionExpired() = sessionExpirationListener.onSessionExpired() +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationListener.kt b/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationListener.kt new file mode 100644 index 0000000..3984a2b --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/session/SessionExpirationListener.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.core.session + +interface SessionExpirationListener { + fun onSessionExpired() +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/shared/AnswerUtils.kt b/core/src/main/java/org/fnives/test/showcase/core/shared/AnswerUtils.kt new file mode 100644 index 0000000..cf7bad0 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/shared/AnswerUtils.kt @@ -0,0 +1,25 @@ +package org.fnives.test.showcase.core.shared + +import kotlinx.coroutines.CancellationException +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException + +internal suspend fun wrapIntoAnswer(callback: suspend () -> T): Answer = + try { + Answer.Success(callback()) + } catch (networkException: NetworkException) { + Answer.Error(networkException) + } catch (parsingException: ParsingException) { + Answer.Error(parsingException) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (throwable: Throwable) { + Answer.Error(UnexpectedException(throwable)) + } + +internal fun Answer.mapIntoResource() = when (this) { + is Answer.Error -> Resource.Error(error) + is Answer.Success -> Resource.Success(data) +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/shared/Optional.kt b/core/src/main/java/org/fnives/test/showcase/core/shared/Optional.kt new file mode 100644 index 0000000..939d91b --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/shared/Optional.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.core.shared + +internal class Optional(val item: T?) diff --git a/core/src/main/java/org/fnives/test/showcase/core/shared/UnexpectedException.kt b/core/src/main/java/org/fnives/test/showcase/core/shared/UnexpectedException.kt new file mode 100644 index 0000000..2c7a74d --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/shared/UnexpectedException.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.core.shared + +class UnexpectedException(cause: Throwable) : RuntimeException(cause.message, cause) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return this.cause == (other as UnexpectedException).cause + } + + override fun hashCode(): Int = super.hashCode() + cause.hashCode() +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapter.kt b/core/src/main/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapter.kt new file mode 100644 index 0000000..a9d5d77 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapter.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.core.storage + +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage + +internal class NetworkSessionLocalStorageAdapter( + private val userDataLocalStorage: UserDataLocalStorage +) : NetworkSessionLocalStorage { + + override var session: Session? + get() = userDataLocalStorage.session + set(value) { + userDataLocalStorage.session = value + } +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/storage/UserDataLocalStorage.kt b/core/src/main/java/org/fnives/test/showcase/core/storage/UserDataLocalStorage.kt new file mode 100644 index 0000000..919c00f --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/storage/UserDataLocalStorage.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.core.storage + +import org.fnives.test.showcase.model.session.Session + +interface UserDataLocalStorage { + var session: Session? +} diff --git a/core/src/main/java/org/fnives/test/showcase/core/storage/content/FavouriteContentLocalStorage.kt b/core/src/main/java/org/fnives/test/showcase/core/storage/content/FavouriteContentLocalStorage.kt new file mode 100644 index 0000000..c778e45 --- /dev/null +++ b/core/src/main/java/org/fnives/test/showcase/core/storage/content/FavouriteContentLocalStorage.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.core.storage.content + +import kotlinx.coroutines.flow.Flow +import org.fnives.test.showcase.model.content.ContentId + +interface FavouriteContentLocalStorage { + + fun observeFavourites(): Flow> + + suspend fun markAsFavourite(contentId: ContentId) + + suspend fun deleteAsFavourite(contentId: ContentId) +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt new file mode 100644 index 0000000..f146004 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/AddContentToFavouriteUseCaseTest.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class AddContentToFavouriteUseCaseTest { + + private lateinit var sut: AddContentToFavouriteUseCase + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + sut = AddContentToFavouriteUseCase(mockFavouriteContentLocalStorage) + } + + @Test + fun WHEN_nothing_happens_THEN_the_storage_is_not_touched() { + verifyZeroInteractions(mockFavouriteContentLocalStorage) + } + + @Test + fun GIVEN_contentId_WHEN_called_THEN_storage_is_called() = runBlockingTest { + sut.invoke(ContentId("a")) + + verify(mockFavouriteContentLocalStorage, times(1)).markAsFavourite(ContentId("a")) + verifyNoMoreInteractions(mockFavouriteContentLocalStorage) + } + + @Test + fun GIVEN_throwing_local_storage_WHEN_thrown_THEN_its_thrown() = runBlockingTest { + whenever(mockFavouriteContentLocalStorage.markAsFavourite(ContentId("a"))).doThrow(RuntimeException()) + + assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke(ContentId("a")) } + } + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt new file mode 100644 index 0000000..5c33c9c --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/ContentRepositoryTest.kt @@ -0,0 +1,153 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.core.shared.UnexpectedException +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.fnives.test.showcase.network.content.ContentRemoteSource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class ContentRepositoryTest { + + private lateinit var sut: ContentRepository + private lateinit var mockContentRemoteSource: ContentRemoteSource + private lateinit var testDispatcher: TestCoroutineDispatcher + + @BeforeEach + fun setUp() { + testDispatcher = TestCoroutineDispatcher() + mockContentRemoteSource = mock() + sut = ContentRepository(mockContentRemoteSource) + } + + @Test + fun GIVEN_no_interaction_THEN_remote_source_is_not_called() { + verifyNoMoreInteractions(mockContentRemoteSource) + } + + @Test + fun GIVEN_no_response_from_remote_source_WHEN_content_observed_THEN_loading_is_returned() = + runBlockingTest(testDispatcher) { + val expected = Resource.Loading>() + val suspendedRequest = CompletableDeferred() + whenever(mockContentRemoteSource.get()).doSuspendableAnswer { + suspendedRequest.await() + emptyList() + } + val actual = sut.contents.take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + suspendedRequest.complete(Unit) + } + + @Test + fun GIVEN_content_response_WHEN_content_observed_THEN_loading_AND_data_is_returned() = + runBlockingTest(testDispatcher) { + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(Content(ContentId("a"), "", "", ImageUrl("")))) + ) + whenever(mockContentRemoteSource.get()) + .doReturn(listOf(Content(ContentId("a"), "", "", ImageUrl("")))) + + val actual = sut.contents.take(2).toList() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_content_error_WHEN_content_observed_THEN_loading_AND_data_is_returned() = + runBlockingTest(testDispatcher) { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + whenever(mockContentRemoteSource.get()).doThrow(exception) + + val actual = sut.contents.take(2).toList() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_content_response_THEN_error_WHEN_fetched_THEN_returned_states_are_loading_data_loading_error() = + runBlockingTest(testDispatcher) { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + val actual = async(testDispatcher) { sut.contents.take(4).toList() } + testDispatcher.advanceUntilIdle() + sut.fetch() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } + + @Test + fun GIVEN_content_response_THEN_error_WHEN_fetched_THEN_only_4_items_are_emitted() { + Assertions.assertThrows(IllegalStateException::class.java) { + runBlockingTest(testDispatcher) { + val exception = RuntimeException() + val expected = listOf( + Resource.Loading(), + Resource.Success(emptyList()), + Resource.Loading(), + Resource.Error>(UnexpectedException(exception)) + ) + var first = true + whenever(mockContentRemoteSource.get()).doAnswer { + if (first) emptyList().also { first = false } else throw exception + } + + val actual = async(testDispatcher) { sut.contents.take(5).toList() } + testDispatcher.advanceUntilIdle() + sut.fetch() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } + } + } + + @Test + fun GIVEN_saved_cache_WHEN_collected_THEN_cache_is_returned() = runBlockingTest { + val content = Content(ContentId("1"), "", "", ImageUrl("")) + val expected = listOf(Resource.Success(listOf(content))) + whenever(mockContentRemoteSource.get()).doReturn(listOf(content)) + sut.contents.take(2).toList() + + val actual = sut.contents.take(1).toList() + + verify(mockContentRemoteSource, times(1)).get() + Assertions.assertEquals(expected, actual) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt new file mode 100644 index 0000000..8256cfd --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/FetchContentUseCaseTest.kt @@ -0,0 +1,49 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class FetchContentUseCaseTest { + + private lateinit var sut: FetchContentUseCase + private lateinit var mockContentRepository: ContentRepository + + @BeforeEach + fun setUp() { + mockContentRepository = mock() + sut = FetchContentUseCase(mockContentRepository) + } + + @Test + fun WHEN_nothing_happens_THEN_the_storage_is_not_touched() { + verifyZeroInteractions(mockContentRepository) + } + + @Test + fun WHEN_called_THEN_repository_is_called() = runBlockingTest { + sut.invoke() + + verify(mockContentRepository, times(1)).fetch() + verifyNoMoreInteractions(mockContentRepository) + } + + @Test + fun GIVEN_throwing_local_storage_WHEN_thrown_THEN_its_thrown() = runBlockingTest { + whenever(mockContentRepository.fetch()).doThrow(RuntimeException()) + + assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke() } + } + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt new file mode 100644 index 0000000..1fd4406 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/GetAllContentUseCaseTest.kt @@ -0,0 +1,214 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.FavouriteContent +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.shared.Resource +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class GetAllContentUseCaseTest { + + private lateinit var sut: GetAllContentUseCase + private lateinit var mockContentRepository: ContentRepository + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + private lateinit var contentFlow: MutableStateFlow>> + private lateinit var favouriteContentIdFlow: MutableStateFlow> + private lateinit var testDispatcher: TestCoroutineDispatcher + + @BeforeEach + fun setUp() { + testDispatcher = TestCoroutineDispatcher() + mockFavouriteContentLocalStorage = mock() + mockContentRepository = mock() + favouriteContentIdFlow = MutableStateFlow(emptyList()) + contentFlow = MutableStateFlow(Resource.Loading()) + whenever(mockFavouriteContentLocalStorage.observeFavourites()).doReturn(favouriteContentIdFlow) + whenever(mockContentRepository.contents).doReturn(contentFlow) + sut = GetAllContentUseCase(mockContentRepository, mockFavouriteContentLocalStorage) + } + + @Test + fun GIVEN_loading_AND_empty_favourite_WHEN_observed_THEN_loading_is_shown() = runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = emptyList() + contentFlow.value = Resource.Loading() + val expected = Resource.Loading>() + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @Test + fun GIVEN_loading_AND_listOfFavourite_WHEN_observed_THEN_loading_is_shown() = runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = listOf(ContentId("a")) + contentFlow.value = Resource.Loading() + val expected = Resource.Loading>() + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @Test + fun GIVEN_error_AND_empty_favourite_WHEN_observed_THEN_error_is_shown() = runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = emptyList() + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = Resource.Error>(exception) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @Test + fun GIVEN_error_AND_listOfFavourite_WHEN_observed_THEN_error_is_shown() = runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = listOf(ContentId("b")) + val exception = Throwable() + contentFlow.value = Resource.Error(exception) + val expected = Resource.Error>(exception) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @Test + fun GIVEN_listOfContent_AND_empty_favourite_WHEN_observed_THEN_favourites_are_returned() = runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = Resource.Success(items) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @Test + fun GIVEN_listOfContent_AND_other_favourite_id_WHEN_observed_THEN_favourites_are_returned() = + runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = listOf(ContentId("x")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, false) + ) + val expected = Resource.Success(items) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @Test + fun GIVEN_listOfContent_AND_same_favourite_id_WHEN_observed_THEN_favourites_are_returned() = + runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Success(listOf(content)) + val items = listOf( + FavouriteContent(content, true) + ) + val expected = Resource.Success(items) + + val actual = sut.get().take(1).toList() + + Assertions.assertEquals(listOf(expected), actual) + } + + @Test + fun GIVEN_loading_then_data_then_added_favourite_WHEN_observed_THEN_loading_then_correct_favourites_are_returned() = + runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = emptyList() + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, false))), + Resource.Success(listOf(FavouriteContent(content, true))) + ) + + val actual = async(testDispatcher) { + sut.get().take(3).toList() + } + testDispatcher.advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + testDispatcher.advanceUntilIdle() + + favouriteContentIdFlow.value = listOf(ContentId("a")) + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } + + @Test + fun GIVEN_loading_then_data_then_removed_favourite_WHEN_observed_THEN_loading_then_correct_favourites_are_returned() = + runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Success(listOf(FavouriteContent(content, false))) + ) + + val actual = async(testDispatcher) { + sut.get().take(3).toList() + } + testDispatcher.advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + testDispatcher.advanceUntilIdle() + + favouriteContentIdFlow.value = emptyList() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } + + @Test + fun GIVEN_loading_then_data_then_loading_WHEN_observed_THEN_loading_then_correct_favourites_then_loadingare_returned() = + runBlockingTest(testDispatcher) { + favouriteContentIdFlow.value = listOf(ContentId("a")) + val content = Content(ContentId("a"), "b", "c", ImageUrl("d")) + contentFlow.value = Resource.Loading() + val expected = listOf( + Resource.Loading(), + Resource.Success(listOf(FavouriteContent(content, true))), + Resource.Loading() + ) + + val actual = async(testDispatcher) { + sut.get().take(3).toList() + } + testDispatcher.advanceUntilIdle() + + contentFlow.value = Resource.Success(listOf(content)) + testDispatcher.advanceUntilIdle() + + contentFlow.value = Resource.Loading() + testDispatcher.advanceUntilIdle() + + Assertions.assertEquals(expected, actual.await()) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt new file mode 100644 index 0000000..c3d18da --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/content/RemoveContentFromFavouritesUseCaseTest.kt @@ -0,0 +1,51 @@ +package org.fnives.test.showcase.core.content + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.core.storage.content.FavouriteContentLocalStorage +import org.fnives.test.showcase.model.content.ContentId +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class RemoveContentFromFavouritesUseCaseTest { + + private lateinit var sut: RemoveContentFromFavouritesUseCase + private lateinit var mockFavouriteContentLocalStorage: FavouriteContentLocalStorage + + @BeforeEach + fun setUp() { + mockFavouriteContentLocalStorage = mock() + sut = RemoveContentFromFavouritesUseCase(mockFavouriteContentLocalStorage) + } + + @Test + fun WHEN_nothing_happens_THEN_the_storage_is_not_touched() { + verifyZeroInteractions(mockFavouriteContentLocalStorage) + } + + @Test + fun GIVEN_contentId_WHEN_called_THEN_storage_is_called() = runBlockingTest { + sut.invoke(ContentId("a")) + + verify(mockFavouriteContentLocalStorage, times(1)).deleteAsFavourite(ContentId("a")) + verifyNoMoreInteractions(mockFavouriteContentLocalStorage) + } + + @Test + fun GIVEN_throwing_local_storage_WHEN_thrown_THEN_its_thrown() = runBlockingTest { + whenever(mockFavouriteContentLocalStorage.deleteAsFavourite(ContentId("a"))).doThrow(RuntimeException()) + + Assertions.assertThrows(RuntimeException::class.java) { + runBlocking { sut.invoke(ContentId("a")) } + } + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCaseTest.kt new file mode 100644 index 0000000..3b9d0a0 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/login/IsUserLoggedInUseCaseTest.kt @@ -0,0 +1,61 @@ +package org.fnives.test.showcase.core.login + +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.session.Session +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class IsUserLoggedInUseCaseTest { + + private lateinit var sut: IsUserLoggedInUseCase + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + sut = IsUserLoggedInUseCase(mockUserDataLocalStorage) + } + + @Test + fun WHEN_nothing_is_called_THEN_storage_is_not_called() { + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_session_data_saved_WHEN_is_user_logged_in_checked_THEN_true_is_returned() { + whenever(mockUserDataLocalStorage.session).doReturn(Session("a", "b")) + + val actual = sut.invoke() + + Assertions.assertEquals(true, actual) + } + + @Test + fun GIVEN_no_session_data_saved_WHEN_is_user_logged_in_checked_THEN_false_is_returned() { + whenever(mockUserDataLocalStorage.session).doReturn(null) + + val actual = sut.invoke() + + Assertions.assertEquals(false, actual) + } + + @Test + fun GIVEN_no_session_THEN_session_THEN_no_session_WHEN_is_user_logged_in_checked_over_again_THEN_every_return_is_correct() { + whenever(mockUserDataLocalStorage.session).doReturn(null) + val actual1 = sut.invoke() + whenever(mockUserDataLocalStorage.session).doReturn(Session("", "")) + val actual2 = sut.invoke() + whenever(mockUserDataLocalStorage.session).doReturn(null) + val actual3 = sut.invoke() + + Assertions.assertEquals(false, actual1) + Assertions.assertEquals(true, actual2) + Assertions.assertEquals(false, actual3) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt new file mode 100644 index 0000000..399dab7 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/login/LoginUseCaseTest.kt @@ -0,0 +1,95 @@ +package org.fnives.test.showcase.core.login + +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.core.shared.UnexpectedException +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.auth.LoginStatus +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.network.auth.LoginRemoteSource +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyZeroInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class LoginUseCaseTest { + + private lateinit var sut: LoginUseCase + private lateinit var mockLoginRemoteSource: LoginRemoteSource + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockLoginRemoteSource = mock() + mockUserDataLocalStorage = mock() + sut = LoginUseCase(mockLoginRemoteSource, mockUserDataLocalStorage) + } + + @Test + fun GIVEN_empty_username_WHEN_trying_to_login_THEN_invalid_username_is_returned() = runBlockingTest { + val expected = Answer.Success(LoginStatus.INVALID_USERNAME) + + val actual = sut.invoke(LoginCredentials("", "a")) + + Assertions.assertEquals(expected, actual) + verifyZeroInteractions(mockLoginRemoteSource) + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_empty_password_WHEN_trying_to_login_THEN_invalid_password_is_returned() = runBlockingTest { + val expected = Answer.Success(LoginStatus.INVALID_PASSWORD) + + val actual = sut.invoke(LoginCredentials("a", "")) + + Assertions.assertEquals(expected, actual) + verifyZeroInteractions(mockLoginRemoteSource) + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_login_invalid_credentials_response_WHEN_trying_to_login_THEN_invalid_credentials_is_returned() = runBlockingTest { + val expected = Answer.Success(LoginStatus.INVALID_CREDENTIALS) + whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) + .doReturn(LoginStatusResponses.InvalidCredentials) + + val actual = sut.invoke(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_valid_login_response_WHEN_trying_to_login_THEN_Success_is_returned() = runBlockingTest { + val expected = Answer.Success(LoginStatus.SUCCESS) + whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) + .doReturn(LoginStatusResponses.Success(Session("c", "d"))) + + val actual = sut.invoke(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + verify(mockUserDataLocalStorage, times(1)).session = Session("c", "d") + } + + @Test + fun GIVEN_throwing_remote_source_WHEN_trying_to_login_THEN_error_is_returned() = runBlockingTest { + val exception = RuntimeException() + val expected = Answer.Error(UnexpectedException(exception)) + whenever(mockLoginRemoteSource.login(LoginCredentials("a", "b"))) + .doThrow(exception) + + val actual = sut.invoke(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + verifyZeroInteractions(mockUserDataLocalStorage) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/login/LogoutUseCaseTest.kt b/core/src/test/java/org/fnives/test/showcase/core/login/LogoutUseCaseTest.kt new file mode 100644 index 0000000..ffb1900 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/login/LogoutUseCaseTest.kt @@ -0,0 +1,65 @@ +package org.fnives.test.showcase.core.login + +import kotlinx.coroutines.test.runBlockingTest +import org.fnives.test.showcase.core.content.ContentRepository +import org.fnives.test.showcase.core.di.createCoreModule +import org.fnives.test.showcase.core.storage.UserDataLocalStorage +import org.fnives.test.showcase.model.network.BaseUrl +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.verifyZeroInteractions + +@Suppress("TestFunctionName") +internal class LogoutUseCaseTest : KoinTest { + + private lateinit var sut: LogoutUseCase + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + sut = LogoutUseCase(mockUserDataLocalStorage) + startKoin { + modules( + createCoreModule( + baseUrl = BaseUrl("https://a.b.com"), + enableNetworkLogging = true, + favouriteContentLocalStorageProvider = { mock() }, + sessionExpirationListenerProvider = { mock() }, + userDataLocalStorageProvider = { mock() } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun WHEN_no_call_THEN_storage_is_not_interacted() { + verifyZeroInteractions(mockUserDataLocalStorage) + } + + @Test + fun WHEN_logout_invoked_THEN_storage_is_cleared() = runBlockingTest { + val repositoryBefore = getKoin().get() + + sut.invoke() + + val repositoryAfter = getKoin().get() + verify(mockUserDataLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockUserDataLocalStorage) + Assertions.assertNotSame(repositoryBefore, repositoryAfter) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/session/SessionExpirationAdapterTest.kt b/core/src/test/java/org/fnives/test/showcase/core/session/SessionExpirationAdapterTest.kt new file mode 100644 index 0000000..8e1df4f --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/session/SessionExpirationAdapterTest.kt @@ -0,0 +1,35 @@ +package org.fnives.test.showcase.core.session + +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 + +@Suppress("TestFunctionName") +internal class SessionExpirationAdapterTest { + + private lateinit var sut: SessionExpirationAdapter + private lateinit var mockSessionExpirationListener: SessionExpirationListener + + @BeforeEach + fun setUp() { + mockSessionExpirationListener = mock() + sut = SessionExpirationAdapter(mockSessionExpirationListener) + } + + @Test + fun WHEN_onSessionExpired_is_called_THEN_its_delegated() { + sut.onSessionExpired() + + verify(mockSessionExpirationListener, times(1)).onSessionExpired() + verifyNoMoreInteractions(mockSessionExpirationListener) + } + + @Test + fun WHEN_nothing_is_changed_THEN_delegate_is_not_touched() { + verifyZeroInteractions(mockSessionExpirationListener) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt b/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt new file mode 100644 index 0000000..7706bb4 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/shared/AnswerUtilsKtTest.kt @@ -0,0 +1,79 @@ +package org.fnives.test.showcase.core.shared + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.shared.Answer +import org.fnives.test.showcase.model.shared.Resource +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.Test + +@Suppress("TestFunctionName") +internal class AnswerUtilsKtTest { + + @Test + fun GIVEN_network_exception_thrown_WHEN_wrapped_into_answer_THEN_answer_error_is_returned() = runBlocking { + val exception = NetworkException(Throwable()) + val expected = Answer.Error(exception) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_parsing_exception_thrown_WHEN_wrapped_into_answer_THEN_answer_error_is_returned() = runBlocking { + val exception = ParsingException(Throwable()) + val expected = Answer.Error(exception) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_parsing_throwable_thrown_WHEN_wrapped_into_answer_THEN_answer_error_is_returned() = runBlocking { + val exception = Throwable() + val expected = Answer.Error(UnexpectedException(exception)) + + val actual = wrapIntoAnswer { throw exception } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_string_WHEN_wrapped_into_answer_THEN_string_answer_is_returned() = runBlocking { + val expected = Answer.Success("banan") + + val actual = wrapIntoAnswer { "banan" } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_cancellation_exception_WHEN_wrapped_into_answer_THEN_cancellation_exception_is_thrown() { + Assertions.assertThrows(CancellationException::class.java) { + runBlocking { wrapIntoAnswer { throw CancellationException() } } + } + } + + @Test + fun GIVEN_success_answer_WHEN_converted_into_resource_THEN_Resource_success_is_returned() { + val expected = Resource.Success("alma") + + val actual = Answer.Success("alma").mapIntoResource() + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_error_answer_WHEN_converted_into_resource_THEN_Resource_error_is_returned() { + val exception = Throwable() + val expected = Resource.Error(exception) + + val actual = Answer.Error(exception).mapIntoResource() + + Assertions.assertEquals(expected, actual) + } +} diff --git a/core/src/test/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapterTest.kt b/core/src/test/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapterTest.kt new file mode 100644 index 0000000..6202975 --- /dev/null +++ b/core/src/test/java/org/fnives/test/showcase/core/storage/NetworkSessionLocalStorageAdapterTest.kt @@ -0,0 +1,55 @@ +package org.fnives.test.showcase.core.storage + +import org.fnives.test.showcase.model.session.Session +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +internal class NetworkSessionLocalStorageAdapterTest { + + private lateinit var sut: NetworkSessionLocalStorageAdapter + private lateinit var mockUserDataLocalStorage: UserDataLocalStorage + + @BeforeEach + fun setUp() { + mockUserDataLocalStorage = mock() + sut = NetworkSessionLocalStorageAdapter(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_null_as_session_WHEN_saved_THEN_its_delegated() { + sut.session = null + + verify(mockUserDataLocalStorage, times(1)).session = null + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @Test + fun GIVEN_session_WHEN_saved_THEN_its_delegated() { + val expected = Session("a", "b") + + sut.session = Session("a", "b") + + verify(mockUserDataLocalStorage, times(1)).session = expected + verifyNoMoreInteractions(mockUserDataLocalStorage) + } + + @Test + fun WHEN_session_requested_THEN_its_returned_from_delegated() { + val expected = Session("a", "b") + whenever(mockUserDataLocalStorage.session).doReturn(expected) + + val actual = sut.session + + Assertions.assertSame(expected, actual) + verify(mockUserDataLocalStorage, times(1)).session + verifyNoMoreInteractions(mockUserDataLocalStorage) + } +} diff --git a/core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/detekt/detekt.yml b/detekt/detekt.yml new file mode 100644 index 0000000..3e05232 --- /dev/null +++ b/detekt/detekt.yml @@ -0,0 +1,579 @@ +build: + maxIssues: 15 + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + # when writing own rules with new properties, exclude the property path e.g.: "my_rule_set,.*>.*>[my_property]" + excludes: "" + +processors: + active: true + exclude: + # - 'DetektProgressListener' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ClassCountProcessor' + # - 'PackageCountProcessor' + # - 'KtFileCountProcessor' + +console-reports: + active: true + exclude: + # - 'ProjectStatisticsReport' + # - 'ComplexityReport' + # - 'NotificationReport' + # - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'BuildFailureReport' + +comments: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!:]$) + UndocumentedPublicClass: + active: false + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: false + UndocumentedPublicProperty: + active: false + +complexity: + active: true + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + ComplexMethod: + active: true + threshold: 25 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: run,let,apply,with,also,use,forEach,isNotNull,ifNull + LabeledExpression: + active: false + ignoredLabels: "" + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 200 + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 6 + ignoreDefaultParameters: false + MethodOverloading: + active: false + threshold: 6 + NestedBlockDepth: + active: true + threshold: 100 + StringLiteralDuplication: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + thresholdInFiles: 25 + thresholdInClasses: 25 + thresholdInInterfaces: 25 + thresholdInObjects: 25 + thresholdInEnums: 25 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: false + methodNames: 'toString,hashCode,equals,finalize' + InstanceOfCheckForException: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + NotImplementedDeclaration: + active: false + PrintStackTrace: + active: false + RethrowCaughtException: + active: false + ReturnFromFinally: + active: false + ignoreLabeled: false + SwallowedException: + active: false + ignoredExceptionTypes: 'InterruptedException,NumberFormatException,ParseException,MalformedURLException' + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + ThrowingExceptionFromFinally: + active: false + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + exceptions: 'IllegalArgumentException,IllegalStateException,IOException' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + exceptionNames: + - ArrayIndexOutOfBoundsException + - Error + - Exception + - IllegalMonitorStateException + - NullPointerException + - IndexOutOfBoundsException + - RuntimeException + - Throwable + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + TooGenericExceptionThrown: + active: true + exceptionNames: + - Error + - Exception + - Throwable + - RuntimeException + +formatting: + active: true + android: false + autoCorrect: true + AnnotationOnSeparateLine: + active: false + autoCorrect: true + ChainWrapping: + active: true + autoCorrect: true + CommentSpacing: + active: true + autoCorrect: true + Filename: + active: true + FinalNewline: + active: true + autoCorrect: true + ImportOrdering: + active: true + autoCorrect: true + Indentation: + active: true + autoCorrect: true + indentSize: 4 + continuationIndentSize: 4 + MaximumLineLength: + active: true + maxLineLength: 250 + ModifierOrdering: + active: true + autoCorrect: true + MultiLineIfElse: + active: true + autoCorrect: true + NoBlankLineBeforeRbrace: + active: true + autoCorrect: true + NoConsecutiveBlankLines: + active: true + autoCorrect: true + NoEmptyClassBody: + active: true + autoCorrect: true + NoLineBreakAfterElse: + active: true + autoCorrect: true + NoLineBreakBeforeAssignment: + active: true + autoCorrect: true + NoMultipleSpaces: + active: true + autoCorrect: true + NoSemicolons: + active: true + autoCorrect: true + NoTrailingSpaces: + active: true + autoCorrect: true + NoUnitReturn: + active: true + autoCorrect: true + NoUnusedImports: + active: true + autoCorrect: true + NoWildcardImports: + active: true + PackageName: + active: true + autoCorrect: true + ParameterListWrapping: + active: true + autoCorrect: true + indentSize: 4 + SpacingAroundColon: + active: true + autoCorrect: true + SpacingAroundComma: + active: true + autoCorrect: true + SpacingAroundCurly: + active: true + autoCorrect: true + SpacingAroundDot: + active: true + autoCorrect: true + SpacingAroundKeyword: + active: true + autoCorrect: true + SpacingAroundOperators: + active: true + autoCorrect: true + SpacingAroundParens: + active: true + autoCorrect: true + SpacingAroundRangeOperator: + active: true + autoCorrect: true + StringTemplate: + active: true + autoCorrect: true + +naming: + active: true + ClassNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + classPattern: '[A-Z$][a-zA-Z0-9$]*' + ConstructorParameterNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + enumEntryPattern: '^[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + forbiddenName: '' + FunctionMaxLength: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + functionPattern: '^([a-z$][a-zA-Z$0-9]*)|(`.*`)$' + excludeClassPattern: '$^' + ignoreOverridden: true + FunctionParameterNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + InvalidPackageDeclaration: + active: false + rootPackage: '' + MatchingDeclarationName: + active: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + ObjectPropertyNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + packagePattern: '^[a-z]+(\.[a-z][A-Za-z0-9]*)*$' + TopLevelPropertyNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + maximumVariableNameLength: 64 + VariableMinLength: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + minimumVariableNameLength: 1 + VariableNaming: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + +performance: + active: true + ArrayPrimitive: + active: true + ForEachOnRange: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + SpreadOperator: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + Deprecation: + active: false + DuplicateCaseInWhenExpression: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: false + ImplicitDefaultLocale: + active: false + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + excludeAnnotatedProperties: "" + ignoreOnClassesPattern: "" + MissingWhenCase: + active: true + RedundantElseInWhen: + active: true + UnconditionalJumpStatementInLoop: + active: false + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + UnsafeCast: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: true + +style: + active: true + CollapsibleIfStatements: + active: true + DataClassContainsFunctions: + active: true + conversionFunctionPrefix: 'to' + DataClassShouldBeImmutable: + active: false + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: true + includeLineWrapping: false + ForbiddenComment: + active: true + values: 'TODO:,FIXME:,STOPSHIP:' + allowedPatterns: "" + ForbiddenImport: + active: false + imports: '' + forbiddenPatterns: "" + ForbiddenVoid: + active: false + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + excludedFunctions: 'describeContents' + excludeAnnotatedFunction: "dagger.Provides" + LibraryCodeMustSpecifyReturnType: + active: true + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + ignoreNumbers: '-1,0,1,2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + MandatoryBracesIfStatements: + active: true + MaxLineLength: + active: true + maxLineLength: 160 #default: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + MayBeConst: + active: true + ModifierOrder: + active: true + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: true + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 5 + excludedFunctions: "equals" + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 10 + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableDecimalLength: 5 + UnnecessaryAbstractClass: + active: true + excludeAnnotatedClasses: "dagger.Module" + UnnecessaryApply: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: true + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: true + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: "(_|ignored|expected|serialVersionUID)" + UseArrayLiteralsInAnnotations: + active: false + UseCheckOrError: + active: false + UseDataClass: + active: false + excludeAnnotatedClasses: "" + allowVars: false + UseIfInsteadOfWhen: + active: false + UseRequire: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: false + WildcardImport: + active: true + excludes: "**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt" + excludeImports: 'java.util.*,kotlinx.android.synthetic.*' \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..98bed16 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6975649 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Apr 11 21:03:49 EEST 2021 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/gradlescripts/versions.gradle b/gradlescripts/versions.gradle new file mode 100644 index 0000000..576eb20 --- /dev/null +++ b/gradlescripts/versions.gradle @@ -0,0 +1,28 @@ +project.ext { + androidx_core_version = "1.3.2" + androidx_appcompat_version = "1.2.0" + androidx_material_version = "1.3.0" + androidx_constraintlayout_version = "2.0.4" + androidx_livedata_version = "2.3.1" + androidx_swiperefreshlayout_version = "1.1.0" + androidx_room_version = "2.2.6" + + coroutines_version = "1.4.3" + koin_version = "2.2.2" + coil_version = "1.1.1" + retrofit_version = "2.9.0" + okhttp_version = "4.9.1" + moshi_version = "1.11.0" + + testing_androidx_code_version = "1.3.0" + testing_androidx_junit_version = "1.1.2" + testing_androidx_arch_core_version = "2.1.0" + testing_livedata_version = "1.1.2" + testing_kotlin_mockito_version = "3.1.0" + testing_junit5_version = "5.7.0" + testing_json_assert_version = "1.5.0" + testing_junit4_version = "4.13.2" + testing_robolectric_version = "4.5.1" + testing_espresso_version = "3.3.0" + testing_okhttp3_idling_resource_version = "1.0.0" +} \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mockserver/.gitignore b/mockserver/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/mockserver/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/mockserver/build.gradle b/mockserver/build.gradle new file mode 100644 index 0000000..acda750 --- /dev/null +++ b/mockserver/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java-library' + id 'kotlin' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + api project(":model") + + api "com.squareup.okhttp3:mockwebserver:$okhttp_version" + api "com.squareup.okhttp3:okhttp-tls:$okhttp_version" + + implementation "org.skyscreamer:jsonassert:$testing_json_assert_version" + implementation "junit:junit:$testing_junit4_version" + +} \ No newline at end of file diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ContentData.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ContentData.kt new file mode 100644 index 0000000..f187e4c --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ContentData.kt @@ -0,0 +1,45 @@ +package org.fnives.test.showcase.network.mockserver + +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthRequestMatchingChecker +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario + +object ContentData { + + /** + * Returned for [ContentScenario.Success] + */ + val contentSuccess: List = listOf( + Content(ContentId("1"), "title_1", "says_1", ImageUrl("img_1")), + Content(ContentId("2"), "title_2", "says_2", ImageUrl("img_2")), + Content(ContentId("3"), "title_3", "says_3", ImageUrl("img_3")) + ) + + /** + * Returned for [ContentScenario.SuccessWithMissingFields] + */ + val contentSuccessWithMissingFields: List = listOf( + Content(ContentId("1"), "title_1", "says_1", ImageUrl("img_1")) + ) + + /** + * Returned for [AuthScenario.Success] + */ + val loginSuccessResponse = Session("login-access", "login-refresh") + + /** + * Expected for [AuthScenario.Success] + */ + fun createExpectedLoginRequestJson(username: String, password: String) = + AuthRequestMatchingChecker.createExpectedJson(username = username, password = password) + + /** + * Returned for [RefreshTokenScenario.Success] + */ + val refreshSuccessResponse = Session("refreshed-access", "refreshed-refresh") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/MockServerScenarioSetup.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/MockServerScenarioSetup.kt new file mode 100644 index 0000000..6946a59 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/MockServerScenarioSetup.kt @@ -0,0 +1,95 @@ +package org.fnives.test.showcase.network.mockserver + +import okhttp3.mockwebserver.MockWebServer +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario +import java.net.InetAddress + +class MockServerScenarioSetup internal constructor( + private val networkDispatcher: NetworkDispatcher, + private val scenarioToRequestScenario: ScenarioToRequestScenario +) { + + constructor() : this(NetworkDispatcher(), ScenarioToRequestScenario()) + + lateinit var mockWebServer: MockWebServer + private set + var clientCertificates: HandshakeCertificates? = null + private set + + fun start(useHttps: Boolean) { + val mockWebServer = MockWebServer().also { this.mockWebServer = it } + if (useHttps) { + clientCertificates = mockWebServer.useHttps() + } + mockWebServer.dispatcher = networkDispatcher + mockWebServer.start(InetAddress.getLocalHost(), PORT) + } + + /** + * Sets AuthScenario to what to return to the Refresh token request + * @param validateArguments if true the request type / body / headers will be verified, otherwise just the path + */ + fun setScenario(authScenario: AuthScenario, validateArguments: Boolean = true) = apply { + networkDispatcher.set( + NetworkDispatcher.ScenarioType.AUTH, + scenarioToRequestScenario.get(authScenario, validateArguments) + ) + } + + /** + * Sets Scenario to what to return to the Refresh token request + * @param validateArguments if true the request type / body / headers will be verified, otherwise just the path + */ + fun setScenario(refreshTokenScenario: RefreshTokenScenario, validateArguments: Boolean = true) = apply { + networkDispatcher.set( + NetworkDispatcher.ScenarioType.REFRESH, + scenarioToRequestScenario.get(refreshTokenScenario, validateArguments) + ) + } + + /** + * Sets ContentScenario to what to return to the Refresh token request + * @param validateArguments if true the request type / body / headers will be verified, otherwise just the path + */ + fun setScenario(contentScenario: ContentScenario, validateArguments: Boolean = true) = apply { + networkDispatcher.set( + NetworkDispatcher.ScenarioType.CONTENT, + scenarioToRequestScenario.get(contentScenario, validateArguments) + ) + } + + fun takeRequest() = mockWebServer.takeRequest() + + fun stop() { + mockWebServer.shutdown() + } + + companion object { + const val PORT: Int = 7335 + val HTTP_BASE_URL get() = "http://${InetAddress.getLocalHost().hostName}" + val HTTPS_BASE_URL get() = "https://localhost" + + private fun MockWebServer.useHttps(): HandshakeCertificates { + val localhost = InetAddress.getByName("localhost").canonicalHostName + val localhostCertificate = HeldCertificate.Builder() + .addSubjectAlternativeName(localhost) + .build() + + val serverCertificates = HandshakeCertificates.Builder() + .heldCertificate(localhostCertificate) + .build() + + useHttps(serverCertificates.sslSocketFactory(), false) + + val clientCertificates = HandshakeCertificates.Builder() + .addTrustedCertificate(localhostCertificate.certificate) + .build() + + return clientCertificates + } + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/NetworkDispatcher.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/NetworkDispatcher.kt new file mode 100644 index 0000000..f70ee8c --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/NetworkDispatcher.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.network.mockserver + +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.RequestScenario +import org.fnives.test.showcase.network.mockserver.scenario.general.NotFoundRequestScenario + +internal class NetworkDispatcher : Dispatcher() { + + private var scenarios: Map = emptyMap() + + override fun dispatch(request: RecordedRequest): MockResponse = + scenarios.values + .asSequence() + .mapNotNull { it.getResponse(request) } + .firstOrNull() + ?: NotFoundRequestScenario.getResponse(request) + + fun set(type: ScenarioType, scenario: RequestScenario) { + scenarios = scenarios.plus(type to scenario) + } + + enum class ScenarioType { + AUTH, + REFRESH, + CONTENT + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ScenarioToRequestScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ScenarioToRequestScenario.kt new file mode 100644 index 0000000..f699cda --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/ScenarioToRequestScenario.kt @@ -0,0 +1,80 @@ +package org.fnives.test.showcase.network.mockserver + +import org.fnives.test.showcase.network.mockserver.scenario.RequestScenario +import org.fnives.test.showcase.network.mockserver.scenario.RequestScenarioChain +import org.fnives.test.showcase.network.mockserver.scenario.SpecificRequestScenario +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthRequestMatchingChecker +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +import org.fnives.test.showcase.network.mockserver.scenario.auth.CreateAuthInvalidCredentialsResponse +import org.fnives.test.showcase.network.mockserver.scenario.auth.CreateAuthSuccessResponse +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentRequestMatchingChecker +import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario +import org.fnives.test.showcase.network.mockserver.scenario.content.CreateContentSuccessResponse +import org.fnives.test.showcase.network.mockserver.scenario.content.CreateContentSuccessWithMissingFields +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateGeneralErrorResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateGenericSuccessResponseByJson +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateMalformedJsonSuccessResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateUnauthorizedResponse +import org.fnives.test.showcase.network.mockserver.scenario.general.GenericScenario +import org.fnives.test.showcase.network.mockserver.scenario.refresh.CreateRefreshResponse +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshRequestMatchingChecker +import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario + +internal class ScenarioToRequestScenario { + + fun get(authScenario: AuthScenario, validateArguments: Boolean): RequestScenario = + wrap(authScenario, ::convert, validateArguments) + + fun get(contentScenario: ContentScenario, validateArguments: Boolean): RequestScenario = + wrap(contentScenario, ::convert, validateArguments) + + fun get(refreshTokenScenario: RefreshTokenScenario, validateArguments: Boolean): RequestScenario = + wrap(refreshTokenScenario, ::convert, validateArguments) + + private fun convert(validateArguments: Boolean, authScenario: AuthScenario): RequestScenario { + val createResponse = when (authScenario) { + is AuthScenario.GenericError -> CreateGeneralErrorResponse() + is AuthScenario.InvalidCredentials -> CreateAuthInvalidCredentialsResponse() + is AuthScenario.Success -> CreateAuthSuccessResponse() + is AuthScenario.MalformedJsonAsSuccessResponse -> CreateMalformedJsonSuccessResponse() + is AuthScenario.UnexpectedJsonAsSuccessResponse -> CreateGenericSuccessResponseByJson("[]") + } + val requestMatchingChecker = AuthRequestMatchingChecker(authScenario, validateArguments) + return SpecificRequestScenario(requestMatchingChecker, createResponse) + } + + private fun convert(validateArguments: Boolean, contentScenario: ContentScenario): RequestScenario { + val createResponse = when (contentScenario) { + is ContentScenario.Error -> CreateGeneralErrorResponse() + is ContentScenario.Success -> CreateContentSuccessResponse() + is ContentScenario.SuccessWithMissingFields -> CreateContentSuccessWithMissingFields() + is ContentScenario.Unauthorized -> CreateUnauthorizedResponse() + is ContentScenario.MalformedJsonAsSuccessResponse -> CreateMalformedJsonSuccessResponse() + is ContentScenario.UnexpectedJsonAsSuccessResponse -> CreateGenericSuccessResponseByJson("{}") + } + val requestMatchingChecker = ContentRequestMatchingChecker(contentScenario, validateArguments) + return SpecificRequestScenario(requestMatchingChecker, createResponse) + } + + private fun convert(validateArguments: Boolean, refreshTokenScenario: RefreshTokenScenario): RequestScenario { + val contentResponse = when (refreshTokenScenario) { + RefreshTokenScenario.Error -> CreateGeneralErrorResponse() + RefreshTokenScenario.Success -> CreateRefreshResponse() + RefreshTokenScenario.UnexpectedJsonAsSuccessResponse -> CreateGenericSuccessResponseByJson("{}") + RefreshTokenScenario.MalformedJson -> CreateMalformedJsonSuccessResponse() + } + val requestMatchingChecker = RefreshRequestMatchingChecker(validateArguments) + return SpecificRequestScenario(requestMatchingChecker, contentResponse) + } + + private fun > wrap( + scenario: T, + convert: (Boolean, T) -> RequestScenario, + validateArguments: Boolean + ): RequestScenario { + val requestScenario = convert(validateArguments, scenario) + val previousScenario = scenario.previousScenario ?: return requestScenario + + return RequestScenarioChain(current = convert(validateArguments, previousScenario), next = requestScenario) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestMatchingChecker.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestMatchingChecker.kt new file mode 100644 index 0000000..0f97513 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestMatchingChecker.kt @@ -0,0 +1,11 @@ +package org.fnives.test.showcase.network.mockserver.scenario + +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONException +import kotlin.jvm.Throws + +internal interface RequestMatchingChecker { + + @Throws(AssertionError::class, JSONException::class) + fun isValidRequest(request: RecordedRequest): Boolean +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenario.kt new file mode 100644 index 0000000..b817e0b --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenario.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.network.mockserver.scenario + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest + +internal interface RequestScenario { + + fun getResponse(request: RecordedRequest): MockResponse? +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenarioChain.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenarioChain.kt new file mode 100644 index 0000000..8b30efe --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/RequestScenarioChain.kt @@ -0,0 +1,19 @@ +package org.fnives.test.showcase.network.mockserver.scenario + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest + +internal class RequestScenarioChain( + private val current: RequestScenario, + private val next: RequestScenario +) : RequestScenario { + + private var isConsumed = false + + override fun getResponse(request: RecordedRequest): MockResponse? = + if (isConsumed) { + next.getResponse(request) + } else { + current.getResponse(request)?.also { isConsumed = true } + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/SpecificRequestScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/SpecificRequestScenario.kt new file mode 100644 index 0000000..97d1ddf --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/SpecificRequestScenario.kt @@ -0,0 +1,34 @@ +package org.fnives.test.showcase.network.mockserver.scenario + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.json.JSONException + +internal class SpecificRequestScenario( + private val requestMatchingChecker: RequestMatchingChecker, + private val createResponse: CreateResponse +) : RequestScenario { + + override fun getResponse(request: RecordedRequest): MockResponse? = wrapExceptionsIntoMockResponse { + if (requestMatchingChecker.isValidRequest(request)) { + createResponse.getResponse() + } else { + null + } + } + + private fun wrapExceptionsIntoMockResponse(responseFactory: () -> MockResponse?): MockResponse? = + try { + responseFactory() + } catch (jsonException: JSONException) { + MockResponse().setBody("JSONException while asserting your request, message: ${jsonException.message}") + .setResponseCode(400) + } catch (assertionError: AssertionError) { + MockResponse().setBody("AssertionError while asserting your request, message: ${assertionError.message}") + .setResponseCode(400) + } catch (throwable: Throwable) { + MockResponse().setBody("Unexpected Exception while asserting your request, message: ${throwable.message}") + .setResponseCode(400) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthRequestMatchingChecker.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthRequestMatchingChecker.kt new file mode 100644 index 0000000..e37f291 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthRequestMatchingChecker.kt @@ -0,0 +1,39 @@ +package org.fnives.test.showcase.network.mockserver.scenario.auth + +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.RequestMatchingChecker +import org.junit.Assert +import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode + +internal class AuthRequestMatchingChecker( + private val authScenario: AuthScenario, + private val validateArguments: Boolean +) : RequestMatchingChecker { + + override fun isValidRequest(request: RecordedRequest): Boolean { + if (request.path != "/login") return false + if (!validateArguments) return true + + Assert.assertEquals("POST", request.method) + Assert.assertEquals("Android", request.getHeader("Platform")) + Assert.assertEquals(null, request.getHeader("Authorization")) + val expectedJson = createExpectedJson( + username = authScenario.username, + password = authScenario.password + ) + JSONAssert.assertEquals(expectedJson, request.body.readUtf8(), JSONCompareMode.LENIENT) + + return true + } + + companion object { + internal fun createExpectedJson(username: String, password: String): String = + """ + { + "username": "$username", + "password": "$password" + } + """.trimIndent() + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthScenario.kt new file mode 100644 index 0000000..c2418db --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/AuthScenario.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.network.mockserver.scenario.auth + +import org.fnives.test.showcase.network.mockserver.scenario.general.GenericScenario + +sealed class AuthScenario : GenericScenario() { + + abstract val username: String + abstract val password: String + + class Success(override val username: String, override val password: String) : AuthScenario() + class InvalidCredentials(override val username: String, override val password: String) : AuthScenario() + class GenericError(override val username: String, override val password: String) : AuthScenario() + class UnexpectedJsonAsSuccessResponse(override val username: String, override val password: String) : AuthScenario() + class MalformedJsonAsSuccessResponse(override val username: String, override val password: String) : AuthScenario() +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthInvalidCredentialsResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthInvalidCredentialsResponse.kt new file mode 100644 index 0000000..5087e62 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthInvalidCredentialsResponse.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.network.mockserver.scenario.auth + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse + +internal class CreateAuthInvalidCredentialsResponse : CreateResponse { + + override fun getResponse(): MockResponse = + MockResponse().setResponseCode(400).setBody("{}") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthSuccessResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthSuccessResponse.kt new file mode 100644 index 0000000..0daa375 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/auth/CreateAuthSuccessResponse.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.network.mockserver.scenario.auth + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.fnives.test.showcase.network.mockserver.utils.readResourceFile + +internal class CreateAuthSuccessResponse : CreateResponse { + + override fun getResponse(): MockResponse { + val responseBody = readResourceFile("response/auth/success_response_login.json") + return MockResponse().setResponseCode(200).setBody(responseBody) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentRequestMatchingChecker.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentRequestMatchingChecker.kt new file mode 100644 index 0000000..095f4f0 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentRequestMatchingChecker.kt @@ -0,0 +1,29 @@ +package org.fnives.test.showcase.network.mockserver.scenario.content + +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.scenario.RequestMatchingChecker +import org.junit.Assert + +internal class ContentRequestMatchingChecker( + private val contentScenario: ContentScenario, + private val validateArguments: Boolean +) : RequestMatchingChecker { + + override fun isValidRequest(request: RecordedRequest): Boolean { + if (request.path != "/content") return false + if (!validateArguments) return true + + Assert.assertEquals("GET", request.method) + Assert.assertEquals("Android", request.getHeader("Platform")) + val expectedToken = if (contentScenario.usingRefreshedToken) { + ContentData.refreshSuccessResponse.accessToken + } else { + ContentData.loginSuccessResponse.accessToken + } + Assert.assertEquals(expectedToken, request.getHeader("Authorization")) + Assert.assertEquals("", request.body.readUtf8()) + + return true + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentScenario.kt new file mode 100644 index 0000000..b8e4949 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/ContentScenario.kt @@ -0,0 +1,15 @@ +package org.fnives.test.showcase.network.mockserver.scenario.content + +import org.fnives.test.showcase.network.mockserver.scenario.general.GenericScenario + +sealed class ContentScenario : GenericScenario() { + + abstract val usingRefreshedToken: Boolean + + class Success(override val usingRefreshedToken: Boolean) : ContentScenario() + class SuccessWithMissingFields(override val usingRefreshedToken: Boolean) : ContentScenario() + class Unauthorized(override val usingRefreshedToken: Boolean) : ContentScenario() + class Error(override val usingRefreshedToken: Boolean) : ContentScenario() + class UnexpectedJsonAsSuccessResponse(override val usingRefreshedToken: Boolean) : ContentScenario() + class MalformedJsonAsSuccessResponse(override val usingRefreshedToken: Boolean) : ContentScenario() +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessResponse.kt new file mode 100644 index 0000000..da698ef --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessResponse.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.network.mockserver.scenario.content + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.fnives.test.showcase.network.mockserver.utils.readResourceFile + +internal class CreateContentSuccessResponse : CreateResponse { + + override fun getResponse(): MockResponse { + val responseBody = readResourceFile("response/content/success_response_content.json") + return MockResponse().setResponseCode(200).setBody(responseBody) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessWithMissingFields.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessWithMissingFields.kt new file mode 100644 index 0000000..12652c1 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/content/CreateContentSuccessWithMissingFields.kt @@ -0,0 +1,13 @@ +package org.fnives.test.showcase.network.mockserver.scenario.content + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.fnives.test.showcase.network.mockserver.utils.readResourceFile + +internal class CreateContentSuccessWithMissingFields : CreateResponse { + + override fun getResponse(): MockResponse { + val responseBody = readResourceFile("response/content/content_missing_field_response.json") + return MockResponse().setResponseCode(200).setBody(responseBody) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGeneralErrorResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGeneralErrorResponse.kt new file mode 100644 index 0000000..b54fed7 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGeneralErrorResponse.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal class CreateGeneralErrorResponse : CreateResponse { + override fun getResponse(): MockResponse = MockResponse().setResponseCode(500).setBody("{}") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGenericSuccessResponseByJson.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGenericSuccessResponseByJson.kt new file mode 100644 index 0000000..3d57413 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateGenericSuccessResponseByJson.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal class CreateGenericSuccessResponseByJson(val json: String) : CreateResponse { + override fun getResponse(): MockResponse = MockResponse().setResponseCode(200).setBody(json) +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateMalformedJsonSuccessResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateMalformedJsonSuccessResponse.kt new file mode 100644 index 0000000..ec8ea9d --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateMalformedJsonSuccessResponse.kt @@ -0,0 +1,7 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal class CreateMalformedJsonSuccessResponse : CreateResponse { + override fun getResponse(): MockResponse = MockResponse().setResponseCode(200).setBody("[") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateResponse.kt new file mode 100644 index 0000000..b03e846 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateResponse.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal interface CreateResponse { + + fun getResponse(): MockResponse +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateUnauthorizedResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateUnauthorizedResponse.kt new file mode 100644 index 0000000..a6bf0d3 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/createresponse/CreateUnauthorizedResponse.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.network.mockserver.scenario.createresponse + +import okhttp3.mockwebserver.MockResponse + +internal class CreateUnauthorizedResponse : CreateResponse { + override fun getResponse(): MockResponse = + MockResponse().setResponseCode(401).setBody("{}") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/GenericScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/GenericScenario.kt new file mode 100644 index 0000000..772775b --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/GenericScenario.kt @@ -0,0 +1,14 @@ +package org.fnives.test.showcase.network.mockserver.scenario.general + +@Suppress("UnnecessaryAbstractClass") +abstract class GenericScenario> internal constructor() { + + internal var previousScenario: T? = null + private set + + @Suppress("UNCHECKED_CAST") + fun then(scenario: T): T { + scenario.previousScenario = this as T + return scenario + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/NotFoundRequestScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/NotFoundRequestScenario.kt new file mode 100644 index 0000000..fd97a98 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/general/NotFoundRequestScenario.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.network.mockserver.scenario.general + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.RequestScenario + +internal object NotFoundRequestScenario : RequestScenario { + override fun getResponse(request: RecordedRequest): MockResponse = + MockResponse().setResponseCode(404).setBody("Not Found") +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/CreateRefreshResponse.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/CreateRefreshResponse.kt new file mode 100644 index 0000000..5846053 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/CreateRefreshResponse.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.mockserver.scenario.refresh + +import okhttp3.mockwebserver.MockResponse +import org.fnives.test.showcase.network.mockserver.scenario.createresponse.CreateResponse +import org.fnives.test.showcase.network.mockserver.utils.readResourceFile + +internal class CreateRefreshResponse : CreateResponse { + override fun getResponse(): MockResponse { + val responseBody = readResourceFile("response/refresh/success_response_refresh.json") + return MockResponse().setResponseCode(200).setBody(responseBody) + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshRequestMatchingChecker.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshRequestMatchingChecker.kt new file mode 100644 index 0000000..16a5379 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshRequestMatchingChecker.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.network.mockserver.scenario.refresh + +import okhttp3.mockwebserver.RecordedRequest +import org.fnives.test.showcase.network.mockserver.scenario.RequestMatchingChecker +import org.junit.Assert + +internal class RefreshRequestMatchingChecker(val validateArguments: Boolean) : RequestMatchingChecker { + override fun isValidRequest(request: RecordedRequest): Boolean { + if (request.path != "/login/login-refresh" && request.path != "/login/refreshed-refresh") { + return false + } + if (!validateArguments) return true + + Assert.assertEquals("PUT", request.method) + Assert.assertEquals("Android", request.getHeader("Platform")) + Assert.assertEquals(null, request.getHeader("Authorization")) + Assert.assertEquals("", request.body.readUtf8()) + + return true + } +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshTokenScenario.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshTokenScenario.kt new file mode 100644 index 0000000..0234247 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/scenario/refresh/RefreshTokenScenario.kt @@ -0,0 +1,10 @@ +package org.fnives.test.showcase.network.mockserver.scenario.refresh + +import org.fnives.test.showcase.network.mockserver.scenario.general.GenericScenario + +sealed class RefreshTokenScenario : GenericScenario() { + object Success : RefreshTokenScenario() + object Error : RefreshTokenScenario() + object UnexpectedJsonAsSuccessResponse : RefreshTokenScenario() + object MalformedJson : RefreshTokenScenario() +} diff --git a/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/utils/ResourceUtils.kt b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/utils/ResourceUtils.kt new file mode 100644 index 0000000..fd98161 --- /dev/null +++ b/mockserver/src/main/java/org/fnives/test/showcase/network/mockserver/utils/ResourceUtils.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.network.mockserver.utils + +import java.io.BufferedReader +import java.io.InputStreamReader + +internal fun Any.readResourceFile(filePath: String): String = try { + BufferedReader(InputStreamReader(this.javaClass.classLoader.getResourceAsStream(filePath)!!)) + .readLines().joinToString("\n") +} catch (nullPointerException: NullPointerException) { + throw IllegalArgumentException("$filePath file not found!", nullPointerException) +} + +private fun BufferedReader.readLines(): List { + val result = mutableListOf() + use { + do { + readLine()?.let(result::add) ?: return result + } while (true) + } +} diff --git a/mockserver/src/main/resources/response/auth/success_response_login.json b/mockserver/src/main/resources/response/auth/success_response_login.json new file mode 100644 index 0000000..ba930ef --- /dev/null +++ b/mockserver/src/main/resources/response/auth/success_response_login.json @@ -0,0 +1,4 @@ +{ + "accessToken": "login-access", + "refreshToken": "login-refresh" +} \ No newline at end of file diff --git a/mockserver/src/main/resources/response/content/content_missing_field_response.json b/mockserver/src/main/resources/response/content/content_missing_field_response.json new file mode 100644 index 0000000..63eda7f --- /dev/null +++ b/mockserver/src/main/resources/response/content/content_missing_field_response.json @@ -0,0 +1,13 @@ +[ + { + "id": "1", + "title": "title_1", + "says": "says_1", + "image": "img_1" + }, + { + "id": "2", + "title": "title_2", + "says": "says_2" + } +] \ No newline at end of file diff --git a/mockserver/src/main/resources/response/content/success_response_content.json b/mockserver/src/main/resources/response/content/success_response_content.json new file mode 100644 index 0000000..f68e644 --- /dev/null +++ b/mockserver/src/main/resources/response/content/success_response_content.json @@ -0,0 +1,20 @@ +[ + { + "id": "1", + "title": "title_1", + "says": "says_1", + "image": "img_1" + }, + { + "id": "2", + "title": "title_2", + "says": "says_2", + "image": "img_2" + }, + { + "id": "3", + "title": "title_3", + "says": "says_3", + "image": "img_3" + } +] \ No newline at end of file diff --git a/mockserver/src/main/resources/response/refresh/success_response_refresh.json b/mockserver/src/main/resources/response/refresh/success_response_refresh.json new file mode 100644 index 0000000..dee8c87 --- /dev/null +++ b/mockserver/src/main/resources/response/refresh/success_response_refresh.json @@ -0,0 +1,4 @@ +{ + "accessToken": "refreshed-access", + "refreshToken": "refreshed-refresh" +} \ No newline at end of file diff --git a/model/.gitignore b/model/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/model/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/model/build.gradle b/model/build.gradle new file mode 100644 index 0000000..505234c --- /dev/null +++ b/model/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'java-library' + id 'kotlin' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +compileKotlin { + kotlinOptions { + freeCompilerArgs = ["-Xinline-classes"] + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} \ No newline at end of file diff --git a/model/src/main/java/org/fnives/test/showcase/model/auth/LoginCredentials.kt b/model/src/main/java/org/fnives/test/showcase/model/auth/LoginCredentials.kt new file mode 100644 index 0000000..5d2ec47 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/auth/LoginCredentials.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.auth + +data class LoginCredentials(val username: String, val password: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/auth/LoginStatus.kt b/model/src/main/java/org/fnives/test/showcase/model/auth/LoginStatus.kt new file mode 100644 index 0000000..de5891c --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/auth/LoginStatus.kt @@ -0,0 +1,5 @@ +package org.fnives.test.showcase.model.auth + +enum class LoginStatus { + SUCCESS, INVALID_CREDENTIALS, INVALID_USERNAME, INVALID_PASSWORD +} diff --git a/model/src/main/java/org/fnives/test/showcase/model/content/Content.kt b/model/src/main/java/org/fnives/test/showcase/model/content/Content.kt new file mode 100644 index 0000000..2a01267 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/content/Content.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.content + +data class Content(val id: ContentId, val title: String, val description: String, val imageUrl: ImageUrl) diff --git a/model/src/main/java/org/fnives/test/showcase/model/content/ContentId.kt b/model/src/main/java/org/fnives/test/showcase/model/content/ContentId.kt new file mode 100644 index 0000000..c0b2c83 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/content/ContentId.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.content + +inline class ContentId(val id: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/content/FavouriteContent.kt b/model/src/main/java/org/fnives/test/showcase/model/content/FavouriteContent.kt new file mode 100644 index 0000000..66793df --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/content/FavouriteContent.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.content + +data class FavouriteContent(val content: Content, val isFavourite: Boolean) diff --git a/model/src/main/java/org/fnives/test/showcase/model/content/ImageUrl.kt b/model/src/main/java/org/fnives/test/showcase/model/content/ImageUrl.kt new file mode 100644 index 0000000..b304fa8 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/content/ImageUrl.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.content + +inline class ImageUrl(val url: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/network/BaseUrl.kt b/model/src/main/java/org/fnives/test/showcase/model/network/BaseUrl.kt new file mode 100644 index 0000000..1b8a6e4 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/network/BaseUrl.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.network + +inline class BaseUrl(val baseUrl: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/session/Session.kt b/model/src/main/java/org/fnives/test/showcase/model/session/Session.kt new file mode 100644 index 0000000..dd9c331 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/session/Session.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.model.session + +data class Session(val accessToken: String, val refreshToken: String) diff --git a/model/src/main/java/org/fnives/test/showcase/model/shared/Answer.kt b/model/src/main/java/org/fnives/test/showcase/model/shared/Answer.kt new file mode 100644 index 0000000..63d5736 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/shared/Answer.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.model.shared + +sealed class Answer { + data class Success(val data: T) : Answer() + data class Error(val error: Throwable) : Answer() +} diff --git a/model/src/main/java/org/fnives/test/showcase/model/shared/Resource.kt b/model/src/main/java/org/fnives/test/showcase/model/shared/Resource.kt new file mode 100644 index 0000000..f3b5637 --- /dev/null +++ b/model/src/main/java/org/fnives/test/showcase/model/shared/Resource.kt @@ -0,0 +1,20 @@ +package org.fnives.test.showcase.model.shared + +sealed class Resource { + data class Success(val data: T) : Resource() + data class Error(val error: Throwable) : Resource() + class Loading : Resource() { + override fun equals(other: Any?): Boolean = + javaClass == other?.javaClass + + override fun hashCode(): Int = Loading::class.java.hashCode() + + override fun toString(): String = "Resource.Loading()" + } + + abstract override fun equals(other: Any?): Boolean + + abstract override fun hashCode(): Int + + abstract override fun toString(): String +} diff --git a/network/build.gradle b/network/build.gradle new file mode 100644 index 0000000..9a11548 --- /dev/null +++ b/network/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'java-library' + id 'kotlin' + id 'kotlin-kapt' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +test { + useJUnitPlatform() + testLogging { + events 'started', 'passed', 'skipped', 'failed' + exceptionFormat "full" + showStandardStreams true + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + + implementation "com.squareup.retrofit2:retrofit:$retrofit_version" + implementation "com.squareup.retrofit2:converter-moshi:$retrofit_version" + implementation "com.squareup.moshi:moshi:$moshi_version" + kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" + implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" + api "org.koin:koin-core:$koin_version" + + api project(":model") + + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + testImplementation "org.mockito.kotlin:mockito-kotlin:$testing_kotlin_mockito_version" + testImplementation "org.junit.jupiter:junit-jupiter-engine:5.7.0" + testImplementation project(':mockserver') + testImplementation "org.koin:koin-test:$koin_version" + testImplementation "org.skyscreamer:jsonassert:$testing_json_assert_version" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$testing_junit5_version" +} \ No newline at end of file diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/LoginErrorConverter.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginErrorConverter.kt new file mode 100644 index 0000000..49a588b --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginErrorConverter.kt @@ -0,0 +1,35 @@ +package org.fnives.test.showcase.network.auth + +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.auth.model.LoginResponse +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.shared.ExceptionWrapper +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import retrofit2.HttpException +import retrofit2.Response + +internal class LoginErrorConverter { + + @Throws(ParsingException::class) + suspend fun invoke(request: suspend () -> Response): LoginStatusResponses = + ExceptionWrapper.wrap { + val response = request() + if (response.code() == 400) { + return@wrap LoginStatusResponses.InvalidCredentials + } else if (!response.isSuccessful) { + throw HttpException(response) + } + + val parsedResponse = try { + response.body()!! + } catch (nullPointerException: NullPointerException) { + throw ParsingException(nullPointerException) + } + + val session = Session( + accessToken = parsedResponse.accessToken, + refreshToken = parsedResponse.refreshToken + ) + LoginStatusResponses.Success(session) + } +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSource.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSource.kt new file mode 100644 index 0000000..7401011 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSource.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.auth + +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException + +interface LoginRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + suspend fun login(credentials: LoginCredentials): LoginStatusResponses +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceImpl.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceImpl.kt new file mode 100644 index 0000000..248ffde --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceImpl.kt @@ -0,0 +1,27 @@ +package org.fnives.test.showcase.network.auth + +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.auth.model.CredentialsRequest +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.shared.ExceptionWrapper +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException + +internal class LoginRemoteSourceImpl constructor( + private val loginService: LoginService, + private val loginErrorConverter: LoginErrorConverter +) : LoginRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + override suspend fun login(credentials: LoginCredentials): LoginStatusResponses = + loginErrorConverter.invoke { + loginService.login(CredentialsRequest(user = credentials.username, password = credentials.password)) + } + + @Throws(NetworkException::class, ParsingException::class) + internal suspend fun refresh(refreshToken: String): Session = ExceptionWrapper.wrap { + val response = loginService.refreshToken(refreshToken) + Session(accessToken = response.accessToken, refreshToken = response.refreshToken) + } +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/LoginService.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginService.kt new file mode 100644 index 0000000..68dda33 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/LoginService.kt @@ -0,0 +1,18 @@ +package org.fnives.test.showcase.network.auth + +import org.fnives.test.showcase.network.auth.model.CredentialsRequest +import org.fnives.test.showcase.network.auth.model.LoginResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path + +internal interface LoginService { + + @POST("login") + suspend fun login(@Body credentials: CredentialsRequest): Response + + @PUT("login/{token}") + suspend fun refreshToken(@Path("token") token: String): LoginResponse +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/model/CredentialsRequest.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/model/CredentialsRequest.kt new file mode 100644 index 0000000..4e4bd61 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/model/CredentialsRequest.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.auth.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class CredentialsRequest( + @Json(name = "username") + val user: String, + @Json(name = "password") + val password: String +) diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginResponse.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginResponse.kt new file mode 100644 index 0000000..b45413f --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginResponse.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.auth.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class LoginResponse( + @Json(name = "accessToken") + val accessToken: String, + @Json(name = "refreshToken") + val refreshToken: String +) diff --git a/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginStatusResponses.kt b/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginStatusResponses.kt new file mode 100644 index 0000000..11caa81 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/auth/model/LoginStatusResponses.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.network.auth.model + +import org.fnives.test.showcase.model.session.Session + +sealed class LoginStatusResponses { + data class Success(val session: Session) : LoginStatusResponses() + object InvalidCredentials : LoginStatusResponses() +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSource.kt b/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSource.kt new file mode 100644 index 0000000..0e17bd0 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSource.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.content + +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import kotlin.jvm.Throws + +interface ContentRemoteSource { + + @Throws(NetworkException::class, ParsingException::class) + suspend fun get(): List +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImpl.kt b/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImpl.kt new file mode 100644 index 0000000..d1daac8 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImpl.kt @@ -0,0 +1,26 @@ +package org.fnives.test.showcase.network.content + +import org.fnives.test.showcase.model.content.Content +import org.fnives.test.showcase.model.content.ContentId +import org.fnives.test.showcase.model.content.ImageUrl +import org.fnives.test.showcase.network.shared.ExceptionWrapper + +internal class ContentRemoteSourceImpl(private val contentService: ContentService) : ContentRemoteSource { + + override suspend fun get(): List = + ExceptionWrapper.wrap { + contentService.getContent().mapNotNull(::mapResponse) + } + + companion object { + + private fun mapResponse(response: ContentResponse): Content? { + return Content( + id = response.id?.let(::ContentId) ?: return null, + title = response.title ?: return null, + description = response.description ?: return null, + imageUrl = ImageUrl(response.imageUrl ?: return null) + ) + } + } +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/content/ContentResponse.kt b/network/src/main/java/org/fnives/test/showcase/network/content/ContentResponse.kt new file mode 100644 index 0000000..c4c7268 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/content/ContentResponse.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.network.content + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class ContentResponse( + @Json(name = "id") + val id: String?, + @Json(name = "title") + val title: String?, + @Json(name = "image") + val imageUrl: String?, + @Json(name = "says") + val description: String? +) diff --git a/network/src/main/java/org/fnives/test/showcase/network/content/ContentService.kt b/network/src/main/java/org/fnives/test/showcase/network/content/ContentService.kt new file mode 100644 index 0000000..ec6ac14 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/content/ContentService.kt @@ -0,0 +1,9 @@ +package org.fnives.test.showcase.network.content + +import retrofit2.http.GET + +interface ContentService { + + @GET("content") + suspend fun getContent(): List +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/di/OkhttpClientExtension.kt b/network/src/main/java/org/fnives/test/showcase/network/di/OkhttpClientExtension.kt new file mode 100644 index 0000000..2d35d33 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/di/OkhttpClientExtension.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.di + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor + +internal fun OkHttpClient.Builder.setupLogging(enable: Boolean) = run { + if (enable) { + addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }) + } else { + this + } +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/di/createNetworkmodules.kt b/network/src/main/java/org/fnives/test/showcase/network/di/createNetworkmodules.kt new file mode 100644 index 0000000..722cc4c --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/di/createNetworkmodules.kt @@ -0,0 +1,87 @@ +package org.fnives.test.showcase.network.di + +import okhttp3.OkHttpClient +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.auth.LoginErrorConverter +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.session.AuthenticationHeaderInterceptor +import org.fnives.test.showcase.network.session.AuthenticationHeaderUtils +import org.fnives.test.showcase.network.session.NetworkSessionExpirationListener +import org.fnives.test.showcase.network.session.NetworkSessionLocalStorage +import org.fnives.test.showcase.network.session.SessionAuthenticator +import org.fnives.test.showcase.network.shared.PlatformInterceptor +import org.koin.core.module.Module +import org.koin.core.qualifier.StringQualifier +import org.koin.core.scope.Scope +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +fun createNetworkModules( + baseUrl: BaseUrl, + enableLogging: Boolean, + networkSessionLocalStorageProvider: Scope.() -> NetworkSessionLocalStorage, + networkSessionExpirationListenerProvider: Scope.() -> NetworkSessionExpirationListener +): Sequence = + sequenceOf( + loginModule(), + contentModule(), + sessionlessNetworkingModule(baseUrl, enableLogging), + sessionNetworkingModule(networkSessionLocalStorageProvider, networkSessionExpirationListenerProvider) + ) + +private fun loginModule() = module { + factory { LoginRemoteSourceImpl(get(), get()) } + factory { get() } + factory { LoginErrorConverter() } + factory { get(sessionless).create(LoginService::class.java) } +} + +private fun contentModule() = module { + factory { get(session).create(ContentService::class.java) } + factory { ContentRemoteSourceImpl(get()) } + factory { get() } +} + +private fun sessionlessNetworkingModule(baseUrl: BaseUrl, enableLogging: Boolean) = module { + factory { MoshiConverterFactory.create() } + single(qualifier = sessionless, override = true) { + OkHttpClient.Builder() + .addInterceptor(PlatformInterceptor()) + .setupLogging(enableLogging) + .build() + } + single(qualifier = sessionless) { + Retrofit.Builder() + .baseUrl(baseUrl.baseUrl) + .addConverterFactory(get()) + .client(get(sessionless)) + .build() + } +} + +private fun sessionNetworkingModule( + networkSessionLocalStorageProvider: Scope.() -> NetworkSessionLocalStorage, + networkSessionExpirationListenerProvider: Scope.() -> NetworkSessionExpirationListener +) = module { + single { AuthenticationHeaderUtils(get()) } + single { networkSessionExpirationListenerProvider() } + single { networkSessionLocalStorageProvider() } + factory { SessionAuthenticator(get(), get(), get(), get()) } + single(qualifier = session) { + get(sessionless) + .newBuilder() + .authenticator(get()) + .addInterceptor(AuthenticationHeaderInterceptor(get())) + .build() + } + single(qualifier = session) { get(sessionless).newBuilder().client(get(session)).build() } +} + +private val session = StringQualifier("SESSION-NETWORKING") +private val sessionless = StringQualifier("SESSIONLESS-NETWORKING") diff --git a/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderInterceptor.kt b/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderInterceptor.kt new file mode 100644 index 0000000..cfcd1ce --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderInterceptor.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.session + +import okhttp3.Interceptor +import okhttp3.Response + +internal class AuthenticationHeaderInterceptor( + private val authenticationHeaderUtils: AuthenticationHeaderUtils +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(authenticationHeaderUtils.attachToken(chain.request())) +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderUtils.kt b/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderUtils.kt new file mode 100644 index 0000000..cfb35c6 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/AuthenticationHeaderUtils.kt @@ -0,0 +1,16 @@ +package org.fnives.test.showcase.network.session + +import okhttp3.Request + +internal class AuthenticationHeaderUtils(private val networkSessionLocalStorage: NetworkSessionLocalStorage) { + + fun hasToken(okhttpRequest: Request): Boolean = + okhttpRequest.header(KEY) == networkSessionLocalStorage.session?.accessToken + + fun attachToken(okhttpRequest: Request): Request = + okhttpRequest.newBuilder().header(KEY, networkSessionLocalStorage.session?.accessToken.orEmpty()).build() + + companion object { + private const val KEY = "Authorization" + } +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionExpirationListener.kt b/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionExpirationListener.kt new file mode 100644 index 0000000..b8ebd78 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionExpirationListener.kt @@ -0,0 +1,6 @@ +package org.fnives.test.showcase.network.session + +interface NetworkSessionExpirationListener { + + fun onSessionExpired() +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionLocalStorage.kt b/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionLocalStorage.kt new file mode 100644 index 0000000..5cb3c73 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/NetworkSessionLocalStorage.kt @@ -0,0 +1,8 @@ +package org.fnives.test.showcase.network.session + +import org.fnives.test.showcase.model.session.Session + +interface NetworkSessionLocalStorage { + + var session: Session? +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/session/SessionAuthenticator.kt b/network/src/main/java/org/fnives/test/showcase/network/session/SessionAuthenticator.kt new file mode 100644 index 0000000..a5a2cf2 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/session/SessionAuthenticator.kt @@ -0,0 +1,34 @@ +package org.fnives.test.showcase.network.session + +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import org.fnives.test.showcase.network.auth.LoginRemoteSourceImpl + +internal class SessionAuthenticator( + private val networkSessionLocalStorage: NetworkSessionLocalStorage, + private val loginRemoteSource: LoginRemoteSourceImpl, + private val authenticationHeaderUtils: AuthenticationHeaderUtils, + private val networkSessionExpirationListener: NetworkSessionExpirationListener +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + if (authenticationHeaderUtils.hasToken(response.request)) { + return runBlocking { + try { + val newSession = loginRemoteSource.refresh(networkSessionLocalStorage.session?.refreshToken.orEmpty()) + networkSessionLocalStorage.session = newSession + return@runBlocking authenticationHeaderUtils.attachToken(response.request) + } catch (throwable: Throwable) { + networkSessionLocalStorage.session = null + networkSessionExpirationListener.onSessionExpired() + return@runBlocking null + } + } + } else { + return authenticationHeaderUtils.attachToken(response.request) + } + } +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/shared/ExceptionWrapper.kt b/network/src/main/java/org/fnives/test/showcase/network/shared/ExceptionWrapper.kt new file mode 100644 index 0000000..fd9cf74 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/shared/ExceptionWrapper.kt @@ -0,0 +1,27 @@ +package org.fnives.test.showcase.network.shared + +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonEncodingException +import org.fnives.test.showcase.network.shared.exceptions.NetworkException +import org.fnives.test.showcase.network.shared.exceptions.ParsingException +import java.io.EOFException + +internal object ExceptionWrapper { + + @Throws(NetworkException::class, ParsingException::class) + suspend fun wrap(request: suspend () -> T) = try { + request() + } catch (jsonDataException: JsonDataException) { + throw ParsingException(jsonDataException) + } catch (jsonEncodingException: JsonEncodingException) { + throw ParsingException(jsonEncodingException) + } catch (eofException: EOFException) { + throw ParsingException(eofException) + } catch (parsingException: ParsingException) { + throw parsingException + } catch (networkException: NetworkException) { + throw networkException + } catch (throwable: Throwable) { + throw NetworkException(throwable) + } +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/shared/PlatformInterceptor.kt b/network/src/main/java/org/fnives/test/showcase/network/shared/PlatformInterceptor.kt new file mode 100644 index 0000000..a74f951 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/shared/PlatformInterceptor.kt @@ -0,0 +1,12 @@ +package org.fnives.test.showcase.network.shared + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class PlatformInterceptor : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(chain.request().newBuilder().header("Platform", "Android").build()) +} diff --git a/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/NetworkException.kt b/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/NetworkException.kt new file mode 100644 index 0000000..89086bc --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/NetworkException.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.network.shared.exceptions + +class NetworkException(cause: Throwable) : RuntimeException(cause.message, cause) diff --git a/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/ParsingException.kt b/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/ParsingException.kt new file mode 100644 index 0000000..0a6b9b3 --- /dev/null +++ b/network/src/main/java/org/fnives/test/showcase/network/shared/exceptions/ParsingException.kt @@ -0,0 +1,3 @@ +package org.fnives.test.showcase.network.shared.exceptions + +class ParsingException(cause: Throwable) : RuntimeException(cause.message, cause) diff --git a/network/src/test/java/org/fnives/test/showcase/network/auth/LoginErrorConverterTest.kt b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginErrorConverterTest.kt new file mode 100644 index 0000000..6dc87fd --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginErrorConverterTest.kt @@ -0,0 +1,71 @@ +package org.fnives.test.showcase.network.auth + +import com.squareup.moshi.JsonDataException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import okhttp3.internal.http.RealResponseBody +import okio.Buffer +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.auth.model.LoginResponse +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +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 retrofit2.Response +import java.io.IOException + +@Suppress("TestFunctionName") +class LoginErrorConverterTest { + + private lateinit var sut: LoginErrorConverter + + @BeforeEach + fun setUp() { + sut = LoginErrorConverter() + } + + @Test + fun GIVEN_throwing_lambda_WHEN_parsing_login_error_THEN_network_exception_is_thrown() { + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { + sut.invoke { throw IOException() } + } + } + } + + @Test + fun GIVEN_jsonException_throwing_lambda_WHEN_parsing_login_error_THEN_network_exception_is_thrown() { + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { + sut.invoke { throw JsonDataException("") } + } + } + } + + @Test + fun GIVEN_400_error_response_WHEN_parsing_login_error_THEN_invalid_credentials_is_returned() = runBlockingTest { + val expected = LoginStatusResponses.InvalidCredentials + + val actual = sut.invoke { + val responseBody = RealResponseBody(null, 0, Buffer()) + Response.error(400, responseBody) + } + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_successful_response_WHEN_parsing_login_error_THEN_successful_response_is_returned() = runBlockingTest { + val loginResponse = LoginResponse("a", "r") + val expectedSession = Session(accessToken = loginResponse.accessToken, refreshToken = loginResponse.refreshToken) + val expected = LoginStatusResponses.Success(expectedSession) + + val actual = sut.invoke { + Response.success(200, loginResponse) + } + + Assertions.assertEquals(expected, actual) + } +} diff --git a/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceRefreshActionImplTest.kt b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceRefreshActionImplTest.kt new file mode 100644 index 0000000..fc71d92 --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceRefreshActionImplTest.kt @@ -0,0 +1,105 @@ +package org.fnives.test.showcase.network.auth + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.di.createNetworkModules +import org.fnives.test.showcase.network.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.AfterEach +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.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.inject +import org.mockito.kotlin.mock + +@Suppress("TestFunctionName") +class LoginRemoteSourceRefreshActionImplTest : KoinTest { + + private val sut by inject() + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup + get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url), + enableLogging = true, + networkSessionExpirationListenerProvider = { mock() }, + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @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) } + } + } +} diff --git a/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceTest.kt b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceTest.kt new file mode 100644 index 0000000..022e3ca --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/auth/LoginRemoteSourceTest.kt @@ -0,0 +1,120 @@ +package org.fnives.test.showcase.network.auth + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.auth.LoginCredentials +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.auth.model.LoginStatusResponses +import org.fnives.test.showcase.network.di.createNetworkModules +import org.fnives.test.showcase.network.mockserver.ContentData +import org.fnives.test.showcase.network.mockserver.ContentData.createExpectedLoginRequestJson +import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario +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.AfterEach +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.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.inject +import org.mockito.kotlin.mock +import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode + +@Suppress("TestFunctionName") +class LoginRemoteSourceTest : KoinTest { + + private val sut by inject() + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private val mockServerScenarioSetup + get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url), + enableLogging = true, + networkSessionExpirationListenerProvider = mock(), + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun GIVEN_successful_response_WHEN_request_is_fired_THEN_login_status_success_is_returned() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.Success("a", "b")) + val expected = LoginStatusResponses.Success(ContentData.loginSuccessResponse) + + val actual = sut.login(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_successful_response_WHEN_request_is_fired_THEN_the_request_is_setup_properly() = 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) + } + + @Test + fun GIVEN_bad_request_response_WHEN_request_is_fired_THEN_login_status_invalid_credentials_is_returned() = runBlocking { + mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials("a", "b")) + val expected = LoginStatusResponses.InvalidCredentials + + val actual = sut.login(LoginCredentials("a", "b")) + + Assertions.assertEquals(expected, actual) + } + + @Test + fun GIVEN_internal_error_response_WHEN_request_is_fired_THEN_network_exception_is_thrown() { + mockServerScenarioSetup.setScenario(AuthScenario.GenericError("a", "b")) + + Assertions.assertThrows(NetworkException::class.java) { + runBlocking { sut.login(LoginCredentials("a", "b")) } + } + } + + @Test + fun GIVEN_invalid_json_response_WHEN_request_is_fired_THEN_network_exception_is_thrown() { + mockServerScenarioSetup.setScenario(AuthScenario.UnexpectedJsonAsSuccessResponse("a", "b")) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials("a", "b")) } + } + } + + @Test + fun GIVEN_malformed_json_response_WHEN_request_is_fired_THEN_network_exception_is_thrown() { + mockServerScenarioSetup.setScenario(AuthScenario.MalformedJsonAsSuccessResponse("a", "b")) + + Assertions.assertThrows(ParsingException::class.java) { + runBlocking { sut.login(LoginCredentials("a", "b")) } + } + } +} diff --git a/network/src/test/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImplTest.kt b/network/src/test/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImplTest.kt new file mode 100644 index 0000000..f1996d2 --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/content/ContentRemoteSourceImplTest.kt @@ -0,0 +1,124 @@ +package org.fnives.test.showcase.network.content + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.network.di.createNetworkModules +import org.fnives.test.showcase.network.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.AfterEach +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.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.inject +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@Suppress("TestFunctionName") +class ContentRemoteSourceImplTest : KoinTest { + + private val sut: ContentRemoteSourceImpl by inject() + + @RegisterExtension + @JvmField + val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions() + private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage + private val mockServerScenarioSetup + get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup + + @BeforeEach + fun setUp() { + mockNetworkSessionLocalStorage = mock() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url), + enableLogging = true, + networkSessionExpirationListenerProvider = { mock() }, + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @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() } + } + } +} diff --git a/network/src/test/java/org/fnives/test/showcase/network/content/SessionExpirationTest.kt b/network/src/test/java/org/fnives/test/showcase/network/content/SessionExpirationTest.kt new file mode 100644 index 0000000..c7368f3 --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/content/SessionExpirationTest.kt @@ -0,0 +1,114 @@ +package org.fnives.test.showcase.network.content + +import kotlinx.coroutines.runBlocking +import org.fnives.test.showcase.model.network.BaseUrl +import org.fnives.test.showcase.model.session.Session +import org.fnives.test.showcase.network.di.createNetworkModules +import org.fnives.test.showcase.network.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.AfterEach +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.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +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 + +@Suppress("TestFunctionName") +class SessionExpirationTest : KoinTest { + + private val sut: ContentRemoteSourceImpl by inject() + + @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() + startKoin { + modules( + createNetworkModules( + baseUrl = BaseUrl(mockServerScenarioSetupExtensions.url), + enableLogging = true, + networkSessionExpirationListenerProvider = { mockNetworkSessionExpirationListener }, + networkSessionLocalStorageProvider = { mockNetworkSessionLocalStorage } + ).toList() + ) + } + } + + @AfterEach + fun tearDown() { + stopKoin() + } + + @Test + fun GIVEN_401_THEN_refresh_token_ok_response_WHEN_content_requested_THE_tokens_are_refreshed_and_request_retried_with_new_tokens() = + 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) + } + + @Test + fun GIVEN_401_THEN_failing_refresh_WHEN_content_requested_THE_error_is_returned_and_callback_is_Called() = 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() + } +} diff --git a/network/src/test/java/org/fnives/test/showcase/network/shared/MockServerScenarioSetupExtensions.kt b/network/src/test/java/org/fnives/test/showcase/network/shared/MockServerScenarioSetupExtensions.kt new file mode 100644 index 0000000..a74864d --- /dev/null +++ b/network/src/test/java/org/fnives/test/showcase/network/shared/MockServerScenarioSetupExtensions.kt @@ -0,0 +1,21 @@ +package org.fnives.test.showcase.network.shared + +import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class MockServerScenarioSetupExtensions : BeforeEachCallback, AfterEachCallback { + + val url: String = "${MockServerScenarioSetup.HTTP_BASE_URL}:${MockServerScenarioSetup.PORT}/" + lateinit var mockServerScenarioSetup: MockServerScenarioSetup + + override fun beforeEach(context: ExtensionContext?) { + mockServerScenarioSetup = MockServerScenarioSetup() + mockServerScenarioSetup.start(false) + } + + override fun afterEach(context: ExtensionContext?) { + mockServerScenarioSetup.stop() + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..a7f91d5 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,6 @@ +include ':mockserver' +include ':model' +include ':core' +include ':network' +include ':app' +rootProject.name = "TestShowCase" \ No newline at end of file