Issue#41 Copy full example into separate module with Hilt Integration

This commit is contained in:
Gergely Hegedus 2022-09-27 17:16:05 +03:00
parent 69e76dc0da
commit 52a99a82fc
229 changed files with 8416 additions and 11 deletions

1
hilt/hilt-network/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,36 @@
plugins {
id 'java-library'
id 'kotlin'
id 'kotlin-kapt'
id 'java-test-fixtures'
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-moshi:$retrofit_version"
implementation "com.squareup.moshi:moshi:$moshi_version"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
api project(":model")
applyNetworkTestDependenciesTo(this)
// hilt
implementation "com.google.dagger:hilt-core:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
kaptTest "com.google.dagger:dagger-compiler:$hilt_version"
testFixturesApi project(':mockserver')
testFixturesApi "com.squareup.retrofit2:retrofit:$retrofit_version"
testFixturesImplementation "com.google.dagger:hilt-core:$hilt_version"
testFixturesApi "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
}

View file

21
hilt/hilt-network/proguard-rules.pro vendored Normal file
View file

@ -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

View file

@ -0,0 +1,36 @@
package org.fnives.test.showcase.hilt.network.auth
import org.fnives.test.showcase.hilt.network.auth.model.LoginResponse
import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses
import org.fnives.test.showcase.hilt.network.shared.ExceptionWrapper
import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.model.session.Session
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject
internal class LoginErrorConverter @Inject internal constructor() {
@Throws(ParsingException::class)
suspend fun invoke(request: suspend () -> Response<LoginResponse>): 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)
}
}

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.hilt.network.auth
import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses
import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.model.auth.LoginCredentials
interface LoginRemoteSource {
@Throws(NetworkException::class, ParsingException::class)
suspend fun login(credentials: LoginCredentials): LoginStatusResponses
}

View file

@ -0,0 +1,28 @@
package org.fnives.test.showcase.hilt.network.auth
import org.fnives.test.showcase.hilt.network.auth.model.CredentialsRequest
import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses
import org.fnives.test.showcase.hilt.network.shared.ExceptionWrapper
import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.model.session.Session
import javax.inject.Inject
internal class LoginRemoteSourceImpl @Inject internal constructor(
private val loginService: LoginService,
private val loginErrorConverter: LoginErrorConverter
) : LoginRemoteSource {
@Throws(NetworkException::class, ParsingException::class)
override suspend fun login(credentials: LoginCredentials): LoginStatusResponses =
loginErrorConverter.invoke {
loginService.login(CredentialsRequest(user = credentials.username, password = credentials.password))
}
@Throws(NetworkException::class, ParsingException::class)
internal suspend fun refresh(refreshToken: String): Session = ExceptionWrapper.wrap {
val response = loginService.refreshToken(refreshToken)
Session(accessToken = response.accessToken, refreshToken = response.refreshToken)
}
}

View file

@ -0,0 +1,18 @@
package org.fnives.test.showcase.hilt.network.auth
import org.fnives.test.showcase.hilt.network.auth.model.CredentialsRequest
import org.fnives.test.showcase.hilt.network.auth.model.LoginResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
internal interface LoginService {
@POST("login")
suspend fun login(@Body credentials: CredentialsRequest): Response<LoginResponse>
@PUT("login/{token}")
suspend fun refreshToken(@Path("token") token: String): LoginResponse
}

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.hilt.network.auth.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal class CredentialsRequest(
@Json(name = "username")
val user: String,
@Json(name = "password")
val password: String
)

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.hilt.network.auth.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class LoginResponse(
@Json(name = "accessToken")
val accessToken: String,
@Json(name = "refreshToken")
val refreshToken: String
)

View file

@ -0,0 +1,8 @@
package org.fnives.test.showcase.hilt.network.auth.model
import org.fnives.test.showcase.model.session.Session
sealed class LoginStatusResponses {
data class Success(val session: Session) : LoginStatusResponses()
object InvalidCredentials : LoginStatusResponses()
}

View file

@ -0,0 +1,11 @@
package org.fnives.test.showcase.hilt.network.content
import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.model.content.Content
interface ContentRemoteSource {
@Throws(NetworkException::class, ParsingException::class)
suspend fun get(): List<Content>
}

View file

@ -0,0 +1,29 @@
package org.fnives.test.showcase.hilt.network.content
import org.fnives.test.showcase.hilt.network.shared.ExceptionWrapper
import org.fnives.test.showcase.model.content.Content
import org.fnives.test.showcase.model.content.ContentId
import org.fnives.test.showcase.model.content.ImageUrl
import javax.inject.Inject
internal class ContentRemoteSourceImpl @Inject internal constructor(
private val contentService: ContentService
) : ContentRemoteSource {
override suspend fun get(): List<Content> =
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)
)
}
}
}

View file

@ -0,0 +1,16 @@
package org.fnives.test.showcase.hilt.network.content
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
class ContentResponse internal constructor(
@Json(name = "id")
val id: String?,
@Json(name = "title")
val title: String?,
@Json(name = "image")
val imageUrl: String?,
@Json(name = "says")
val description: String?
)

View file

@ -0,0 +1,9 @@
package org.fnives.test.showcase.hilt.network.content
import retrofit2.http.GET
interface ContentService {
@GET("content")
suspend fun getContent(): List<ContentResponse>
}

View file

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

View file

@ -0,0 +1,87 @@
package org.fnives.test.showcase.hilt.network.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSource
import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceImpl
import org.fnives.test.showcase.hilt.network.auth.LoginService
import org.fnives.test.showcase.hilt.network.content.ContentRemoteSource
import org.fnives.test.showcase.hilt.network.content.ContentRemoteSourceImpl
import org.fnives.test.showcase.hilt.network.content.ContentService
import org.fnives.test.showcase.hilt.network.session.AuthenticationHeaderInterceptor
import org.fnives.test.showcase.hilt.network.session.SessionAuthenticator
import org.fnives.test.showcase.hilt.network.shared.PlatformInterceptor
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object HiltNetworkModule {
@Provides
@Singleton
fun provideConverterFactory(): Converter.Factory = MoshiConverterFactory.create()
@Provides
@Singleton
fun provideSessionLessOkHttpClient(enableLogging: Boolean, platformInterceptor: PlatformInterceptor) =
OkHttpClient.Builder()
.addInterceptor(platformInterceptor)
.setupLogging(enableLogging)
.build()
@Provides
@Singleton
@SessionLessQualifier
fun provideSessionLessRetrofit(
baseUrl: String,
converterFactory: Converter.Factory,
@SessionLessQualifier okHttpClient: OkHttpClient,
): Retrofit =
Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(converterFactory)
.client(okHttpClient)
.build()
@Provides
@Singleton
@SessionQualifier
internal fun provideSessionOkHttpClient(
@SessionLessQualifier okHttpClient: OkHttpClient,
sessionAuthenticator: SessionAuthenticator,
authenticationHeaderInterceptor: AuthenticationHeaderInterceptor,
) =
okHttpClient
.newBuilder()
.authenticator(sessionAuthenticator)
.addInterceptor(authenticationHeaderInterceptor)
.build()
@Provides
@Singleton
@SessionQualifier
fun provideSessionRetrofit(@SessionLessQualifier retrofit: Retrofit, @SessionQualifier okHttpClient: OkHttpClient): Retrofit =
retrofit.newBuilder()
.client(okHttpClient)
.build()
@Provides
internal fun bindContentRemoteSource(contentRemoteSourceImpl: ContentRemoteSourceImpl): ContentRemoteSource = contentRemoteSourceImpl
@Provides
internal fun bindLoginRemoteSource(loginRemoteSource: LoginRemoteSourceImpl): LoginRemoteSource = loginRemoteSource
@Provides
internal fun provideLoginService(@SessionLessQualifier retrofit: Retrofit): LoginService =
retrofit.create(LoginService::class.java)
@Provides
internal fun provideContentService(@SessionQualifier retrofit: Retrofit): ContentService =
retrofit.create(ContentService::class.java)
}

View file

@ -0,0 +1,12 @@
package org.fnives.test.showcase.hilt.network.di
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
internal fun OkHttpClient.Builder.setupLogging(enable: Boolean) = run {
if (enable) {
addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY })
} else {
this
}
}

View file

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

View file

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

View file

@ -0,0 +1,13 @@
package org.fnives.test.showcase.hilt.network.session
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
internal class AuthenticationHeaderInterceptor @Inject internal constructor(
private val authenticationHeaderUtils: AuthenticationHeaderUtils
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response =
chain.proceed(authenticationHeaderUtils.attachToken(chain.request()))
}

View file

@ -0,0 +1,20 @@
package org.fnives.test.showcase.hilt.network.session
import okhttp3.Request
import javax.inject.Inject
internal class AuthenticationHeaderUtils @Inject internal constructor(
private val networkSessionLocalStorage: NetworkSessionLocalStorage
) {
fun hasToken(okhttpRequest: Request): Boolean =
okhttpRequest.header(KEY) == networkSessionLocalStorage.session?.accessToken
fun attachToken(okhttpRequest: Request): Request =
okhttpRequest.newBuilder()
.header(KEY, networkSessionLocalStorage.session?.accessToken.orEmpty()).build()
companion object {
private const val KEY = "Authorization"
}
}

View file

@ -0,0 +1,6 @@
package org.fnives.test.showcase.hilt.network.session
interface NetworkSessionExpirationListener {
fun onSessionExpired()
}

View file

@ -0,0 +1,8 @@
package org.fnives.test.showcase.hilt.network.session
import org.fnives.test.showcase.model.session.Session
interface NetworkSessionLocalStorage {
var session: Session?
}

View file

@ -0,0 +1,39 @@
package org.fnives.test.showcase.hilt.network.session
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceImpl
import javax.inject.Inject
internal class SessionAuthenticator @Inject internal constructor(
private val networkSessionLocalStorage: NetworkSessionLocalStorage,
private val loginRemoteSource: LoginRemoteSourceImpl,
private val authenticationHeaderUtils: AuthenticationHeaderUtils,
private val networkSessionExpirationListener: NetworkSessionExpirationListener
) : Authenticator {
@Suppress("SwallowedException")
override fun authenticate(route: Route?, response: Response): Request? {
if (authenticationHeaderUtils.hasToken(response.request)) {
return runBlocking {
try {
val refreshToken = networkSessionLocalStorage.session
?.refreshToken
.orEmpty()
val newSession = loginRemoteSource.refresh(refreshToken)
networkSessionLocalStorage.session = newSession
return@runBlocking authenticationHeaderUtils.attachToken(response.request)
} catch (throwable: Throwable) {
networkSessionLocalStorage.session = null
networkSessionExpirationListener.onSessionExpired()
return@runBlocking null
}
}
} else {
return authenticationHeaderUtils.attachToken(response.request)
}
}
}

View file

@ -0,0 +1,28 @@
package org.fnives.test.showcase.hilt.network.shared
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonEncodingException
import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException
import java.io.EOFException
internal object ExceptionWrapper {
@Suppress("RethrowCaughtException")
@Throws(NetworkException::class, ParsingException::class)
suspend fun <T> 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)
}
}

View file

@ -0,0 +1,13 @@
package org.fnives.test.showcase.hilt.network.shared
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import javax.inject.Inject
class PlatformInterceptor @Inject internal constructor() : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response =
chain.proceed(chain.request().newBuilder().header("Platform", "Android").build())
}

View file

@ -0,0 +1,3 @@
package org.fnives.test.showcase.hilt.network.shared.exceptions
class NetworkException(cause: Throwable) : RuntimeException(cause.message, cause)

View file

@ -0,0 +1,3 @@
package org.fnives.test.showcase.hilt.network.shared.exceptions
class ParsingException(cause: Throwable) : RuntimeException(cause.message, cause)

View file

@ -0,0 +1,78 @@
package org.fnives.test.showcase.hilt.network.auth
import com.squareup.moshi.JsonDataException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.internal.http.RealResponseBody
import okio.Buffer
import org.fnives.test.showcase.hilt.network.auth.model.LoginResponse
import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses
import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.model.session.Session
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import retrofit2.Response
import java.io.IOException
@Suppress("TestFunctionName")
@OptIn(ExperimentalCoroutinesApi::class)
class LoginErrorConverterTest {
private lateinit var sut: LoginErrorConverter
@BeforeEach
fun setUp() {
sut = LoginErrorConverter()
}
@DisplayName("GIVEN throwing lambda WHEN parsing login error THEN network exception is thrown")
@Test
fun generallyThrowingLambdaResultsInNetworkException() {
Assertions.assertThrows(NetworkException::class.java) {
runBlocking {
sut.invoke { throw IOException() }
}
}
}
@DisplayName("GIVEN jsonException throwing lambda WHEN parsing login error THEN network exception is thrown")
@Test
fun jsonDataThrowingLambdaResultsInParsingException() {
Assertions.assertThrows(ParsingException::class.java) {
runBlocking {
sut.invoke { throw JsonDataException("") }
}
}
}
@DisplayName("GIVEN 400 error response WHEN parsing login error THEN invalid credentials is returned")
@Test
fun code400ResponseResultsInInvalidCredentials() = runTest {
val expected = LoginStatusResponses.InvalidCredentials
val actual = sut.invoke {
val responseBody = RealResponseBody(null, 0, Buffer())
Response.error(400, responseBody)
}
Assertions.assertEquals(expected, actual)
}
@DisplayName("GIVEN successful response WHEN parsing login error THEN successful response is returned")
@Test
fun successResponseResultsInSessionResponse() = runTest {
val loginResponse = LoginResponse("a", "r")
val expectedSession = Session(accessToken = loginResponse.accessToken, refreshToken = loginResponse.refreshToken)
val expected = LoginStatusResponses.Success(expectedSession)
val actual = sut.invoke {
Response.success(200, loginResponse)
}
Assertions.assertEquals(expected, actual)
}
}

View file

@ -0,0 +1,99 @@
package org.fnives.test.showcase.hilt.network.auth
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent
import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.koin.test.inject
import org.mockito.kotlin.mock
import javax.inject.Inject
@Suppress("TestFunctionName")
class LoginRemoteSourceRefreshActionImplTest {
@Inject
internal lateinit var sut: LoginRemoteSourceImpl
private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage
@RegisterExtension
@JvmField
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup
@BeforeEach
fun setUp() {
mockNetworkSessionLocalStorage = mock()
DaggerTestNetworkComponent.builder()
.setBaseUrl(mockServerScenarioSetupExtensions.url)
.setEnableLogging(true)
.setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage)
.setNetworkSessionExpirationListener(mock())
.build()
.inject(this)
}
@DisplayName("GIVEN successful response WHEN refresh request is fired THEN session is returned")
@Test
fun successResponseResultsInSession() = runBlocking {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, validateArguments = false)
val expected = ContentData.refreshSuccessResponse
val actual = sut.refresh(ContentData.refreshSuccessResponse.refreshToken)
Assertions.assertEquals(expected, actual)
}
@DisplayName("GIVEN successful response WHEN refresh request is fired THEN the request is setup properly")
@Test
fun refreshRequestIsSetupProperly() = runBlocking {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, validateArguments = false)
sut.refresh(ContentData.refreshSuccessResponse.refreshToken)
val request = mockServerScenarioSetup.takeRequest()
Assertions.assertEquals("PUT", request.method)
Assertions.assertEquals("Android", request.getHeader("Platform"))
Assertions.assertEquals(null, request.getHeader("Authorization"))
Assertions.assertEquals("/login/${ContentData.refreshSuccessResponse.refreshToken}", request.path)
Assertions.assertEquals("", request.body.readUtf8())
}
@DisplayName("GIVEN internal error response WHEN refresh request is fired THEN network exception is thrown")
@Test
fun generalErrorResponseResultsInNetworkException() {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error, validateArguments = false)
Assertions.assertThrows(NetworkException::class.java) {
runBlocking { sut.refresh(ContentData.refreshSuccessResponse.refreshToken) }
}
}
@DisplayName("GIVEN invalid json response WHEN refresh request is fired THEN network exception is thrown")
@Test
fun jsonErrorResponseResultsInParsingException() {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.UnexpectedJsonAsSuccessResponse, validateArguments = false)
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) }
}
}
@DisplayName("GIVEN malformed json response WHEN refresh request is fired THEN parsing exception is thrown")
@Test
fun malformedJsonErrorResponseResultsInParsingException() {
mockServerScenarioSetup.setScenario(RefreshTokenScenario.MalformedJson, validateArguments = false)
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.refresh(ContentData.loginSuccessResponse.refreshToken) }
}
}
}

View file

@ -0,0 +1,146 @@
package org.fnives.test.showcase.hilt.network.auth
import com.squareup.moshi.JsonDataException
import kotlinx.coroutines.runBlocking
import okio.EOFException
import org.fnives.test.showcase.hilt.network.auth.model.LoginStatusResponses
import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent
import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.model.auth.LoginCredentials
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.ContentData.createExpectedLoginRequestJson
import org.fnives.test.showcase.network.mockserver.scenario.auth.AuthScenario
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.mockito.kotlin.mock
import org.skyscreamer.jsonassert.JSONAssert
import org.skyscreamer.jsonassert.JSONCompareMode
import retrofit2.HttpException
import javax.inject.Inject
@Suppress("TestFunctionName")
class LoginRemoteSourceTest {
@Inject
internal lateinit var sut: LoginRemoteSource
@RegisterExtension
@JvmField
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup
@BeforeEach
fun setUp() {
val mockNetworkSessionLocalStorage = mock<NetworkSessionLocalStorage>()
DaggerTestNetworkComponent.builder()
.setBaseUrl(mockServerScenarioSetupExtensions.url)
.setEnableLogging(true)
.setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage)
.setNetworkSessionExpirationListener(mock())
.build()
.inject(this)
}
@DisplayName("GIVEN successful response WHEN request is fired THEN login status success is returned")
@Test
fun successResponseIsParsedProperly() = runBlocking {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"), validateArguments = false)
val expected = LoginStatusResponses.Success(ContentData.loginSuccessResponse)
val actual = sut.login(LoginCredentials(username = "a", password = "b"))
Assertions.assertEquals(expected, actual)
}
@DisplayName("GIVEN successful response WHEN request is fired THEN the request is setup properly")
@Test
fun requestProperlySetup() = runBlocking {
mockServerScenarioSetup.setScenario(AuthScenario.Success(username = "a", password = "b"), validateArguments = false)
sut.login(LoginCredentials(username = "a", password = "b"))
val request = mockServerScenarioSetup.takeRequest()
Assertions.assertEquals("POST", request.method)
Assertions.assertEquals("Android", request.getHeader("Platform"))
Assertions.assertEquals(null, request.getHeader("Authorization"))
Assertions.assertEquals("/login", request.path)
val loginRequest = createExpectedLoginRequestJson(username = "a", password = "b")
JSONAssert.assertEquals(
loginRequest,
request.body.readUtf8(),
JSONCompareMode.NON_EXTENSIBLE
)
}
@DisplayName("GIVEN bad request response WHEN request is fired THEN login status invalid credentials is returned")
@Test
fun badRequestMeansInvalidCredentials() = runBlocking {
mockServerScenarioSetup.setScenario(AuthScenario.InvalidCredentials(username = "a", password = "b"), validateArguments = false)
val expected = LoginStatusResponses.InvalidCredentials
val actual = sut.login(LoginCredentials(username = "a", password = "b"))
Assertions.assertEquals(expected, actual)
}
@DisplayName("GIVEN internal error response WHEN request is fired THEN network exception is thrown")
@Test
fun genericErrorMeansNetworkError() {
mockServerScenarioSetup.setScenario(AuthScenario.GenericError(username = "a", password = "b"), validateArguments = false)
val actual = Assertions.assertThrows(NetworkException::class.java) {
runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) }
}
Assertions.assertEquals("HTTP 500 Server Error", actual.message)
Assertions.assertTrue(actual.cause is HttpException)
}
@DisplayName("GIVEN invalid json response WHEN request is fired THEN network exception is thrown")
@Test
fun invalidJsonMeansParsingException() {
val response = AuthScenario.UnexpectedJsonAsSuccessResponse(username = "a", password = "b")
mockServerScenarioSetup.setScenario(response, validateArguments = false)
val actual = Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) }
}
Assertions.assertEquals("Expected BEGIN_OBJECT but was BEGIN_ARRAY at path \$", actual.message)
Assertions.assertTrue(actual.cause is JsonDataException)
}
@DisplayName("GIVEN json response with missing field WHEN request is fired THEN network exception is thrown")
@Test
fun missingFieldJsonMeansParsingException() {
val response = AuthScenario.MissingFieldJson(username = "a", password = "b")
mockServerScenarioSetup.setScenario(response, validateArguments = false)
val actual = Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.login(LoginCredentials(username = "a", password = "b")) }
}
Assertions.assertEquals("Required value 'accessToken' missing at \$", actual.message)
Assertions.assertTrue(actual.cause is JsonDataException)
}
@DisplayName("GIVEN malformed json response WHEN request is fired THEN network exception is thrown")
@Test
fun malformedJsonMeansParsingException() {
val response = AuthScenario.MalformedJsonAsSuccessResponse(username = "a", "b")
mockServerScenarioSetup.setScenario(response, validateArguments = false)
val actual = Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.login(LoginCredentials(username = "a", "b")) }
}
Assertions.assertEquals("End of input", actual.message)
Assertions.assertTrue(actual.cause is EOFException)
}
}

View file

@ -0,0 +1,123 @@
package org.fnives.test.showcase.hilt.network.content
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.hilt.network.shared.exceptions.ParsingException
import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent
import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.koin.test.KoinTest
import org.koin.test.inject
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import javax.inject.Inject
@Suppress("TestFunctionName")
class ContentRemoteSourceImplTest : KoinTest {
@Inject
internal lateinit var sut: ContentRemoteSourceImpl
@RegisterExtension
@JvmField
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage
private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup
@BeforeEach
fun setUp() {
mockNetworkSessionLocalStorage = mock()
DaggerTestNetworkComponent.builder()
.setBaseUrl(mockServerScenarioSetupExtensions.url)
.setEnableLogging(true)
.setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage)
.setNetworkSessionExpirationListener(mock())
.build()
.inject(this)
}
@DisplayName("GIVEN successful response WHEN getting content THEN its parsed and returned correctly")
@Test
fun successResponseParsing() = runBlocking {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false), validateArguments = false)
val expected = ContentData.contentSuccess
val actual = sut.get()
Assertions.assertEquals(expected, actual)
}
@DisplayName("GIVEN successful response WHEN getting content THEN the request is setup properly")
@Test
fun successResponseRequestIsCorrect() = runBlocking {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.Success(usingRefreshedToken = false), validateArguments = false)
sut.get()
val request = mockServerScenarioSetup.takeRequest()
Assertions.assertEquals("GET", request.method)
Assertions.assertEquals("Android", request.getHeader("Platform"))
Assertions.assertEquals(ContentData.loginSuccessResponse.accessToken, request.getHeader("Authorization"))
Assertions.assertEquals("/content", request.path)
Assertions.assertEquals("", request.body.readUtf8())
}
@DisplayName("GIVEN response with missing Field WHEN getting content THEN invalid is ignored others are returned")
@Test
fun dataMissingFieldIsIgnored() = runBlocking {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
val response = ContentScenario.SuccessWithMissingFields(usingRefreshedToken = false)
mockServerScenarioSetup.setScenario(response, validateArguments = false)
val expected = ContentData.contentSuccessWithMissingFields
val actual = sut.get()
Assertions.assertEquals(expected, actual)
}
@DisplayName("GIVEN error response WHEN getting content THEN network request is thrown")
@Test
fun errorResponseResultsInNetworkException() {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.Error(usingRefreshedToken = false), validateArguments = false)
Assertions.assertThrows(NetworkException::class.java) {
runBlocking { sut.get() }
}
}
@DisplayName("GIVEN unexpected json response WHEN getting content THEN parsing request is thrown")
@Test
fun unexpectedJSONResultsInParsingException() {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
val response = ContentScenario.UnexpectedJsonAsSuccessResponse(usingRefreshedToken = false)
mockServerScenarioSetup.setScenario(response, validateArguments = false)
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.get() }
}
}
@DisplayName("GIVEN malformed json response WHEN getting content THEN parsing request is thrown")
@Test
fun malformedJSONResultsInParsingException() {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
val response = ContentScenario.MalformedJsonAsSuccessResponse(usingRefreshedToken = false)
mockServerScenarioSetup.setScenario(response, validateArguments = false)
Assertions.assertThrows(ParsingException::class.java) {
runBlocking { sut.get() }
}
}
}

View file

@ -0,0 +1,109 @@
package org.fnives.test.showcase.hilt.network.content
import kotlinx.coroutines.runBlocking
import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener
import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage
import org.fnives.test.showcase.hilt.network.shared.exceptions.NetworkException
import org.fnives.test.showcase.hilt.network.testutil.DaggerTestNetworkComponent
import org.fnives.test.showcase.hilt.network.testutil.MockServerScenarioSetupExtensions
import org.fnives.test.showcase.model.session.Session
import org.fnives.test.showcase.network.mockserver.ContentData
import org.fnives.test.showcase.network.mockserver.scenario.content.ContentScenario
import org.fnives.test.showcase.network.mockserver.scenario.refresh.RefreshTokenScenario
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.verifyNoMoreInteractions
import org.mockito.kotlin.whenever
import retrofit2.HttpException
import javax.inject.Inject
@Suppress("TestFunctionName")
class SessionExpirationTest {
@Inject
internal lateinit var sut: ContentRemoteSourceImpl
@RegisterExtension
@JvmField
val mockServerScenarioSetupExtensions = MockServerScenarioSetupExtensions()
private val mockServerScenarioSetup get() = mockServerScenarioSetupExtensions.mockServerScenarioSetup
private lateinit var mockNetworkSessionLocalStorage: NetworkSessionLocalStorage
private lateinit var mockNetworkSessionExpirationListener: NetworkSessionExpirationListener
@BeforeEach
fun setUp() {
mockNetworkSessionLocalStorage = mock()
mockNetworkSessionExpirationListener = mock()
DaggerTestNetworkComponent.builder()
.setBaseUrl(mockServerScenarioSetupExtensions.url)
.setEnableLogging(true)
.setNetworkSessionLocalStorage(mockNetworkSessionLocalStorage)
.setNetworkSessionExpirationListener(mockNetworkSessionExpirationListener)
.build()
.inject(this)
}
@DisplayName("GIVEN 401 THEN refresh token ok response WHEN content requested THE tokens are refreshed and request retried with new tokens")
@Test
fun successRefreshResultsInRequestRetry() = runBlocking {
var sessionToReturnByMock: Session? = ContentData.loginSuccessResponse
mockServerScenarioSetup.setScenario(
ContentScenario.Unauthorized(usingRefreshedToken = false)
.then(ContentScenario.Success(usingRefreshedToken = true)),
validateArguments = false
)
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Success, validateArguments = false)
whenever(mockNetworkSessionLocalStorage.session).doAnswer { sessionToReturnByMock }
doAnswer { sessionToReturnByMock = it.arguments[0] as Session? }
.whenever(mockNetworkSessionLocalStorage).session = anyOrNull()
sut.get()
mockServerScenarioSetup.takeRequest()
val refreshRequest = mockServerScenarioSetup.takeRequest()
val retryAfterTokenRefreshRequest = mockServerScenarioSetup.takeRequest()
Assertions.assertEquals("PUT", refreshRequest.method)
Assertions.assertEquals(
"/login/${ContentData.loginSuccessResponse.refreshToken}",
refreshRequest.path
)
Assertions.assertEquals(null, refreshRequest.getHeader("Authorization"))
Assertions.assertEquals("Android", refreshRequest.getHeader("Platform"))
Assertions.assertEquals("", refreshRequest.body.readUtf8())
Assertions.assertEquals(
ContentData.refreshSuccessResponse.accessToken,
retryAfterTokenRefreshRequest.getHeader("Authorization")
)
verifyNoInteractions(mockNetworkSessionExpirationListener)
}
@DisplayName("GIVEN 401 THEN failing refresh WHEN content requested THE error is returned and callback is Called")
@Test
fun failingRefreshResultsInSessionExpiration() = runBlocking {
whenever(mockNetworkSessionLocalStorage.session).doReturn(ContentData.loginSuccessResponse)
mockServerScenarioSetup.setScenario(ContentScenario.Unauthorized(usingRefreshedToken = false), validateArguments = false)
mockServerScenarioSetup.setScenario(RefreshTokenScenario.Error, validateArguments = false)
val actual = Assertions.assertThrows(NetworkException::class.java) {
runBlocking { sut.get() }
}
Assertions.assertEquals("HTTP 401 Client Error", actual.message)
Assertions.assertTrue(actual.cause is HttpException)
verify(mockNetworkSessionLocalStorage, times(3)).session
verify(mockNetworkSessionLocalStorage, times(1)).session = null
verifyNoMoreInteractions(mockNetworkSessionLocalStorage)
verify(mockNetworkSessionExpirationListener, times(1)).onSessionExpired()
}
}

View file

@ -0,0 +1,44 @@
package org.fnives.test.showcase.hilt.network.testutil
import dagger.BindsInstance
import dagger.Component
import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceRefreshActionImplTest
import org.fnives.test.showcase.hilt.network.auth.LoginRemoteSourceTest
import org.fnives.test.showcase.hilt.network.content.ContentRemoteSourceImplTest
import org.fnives.test.showcase.hilt.network.content.SessionExpirationTest
import org.fnives.test.showcase.hilt.network.di.BindsBaseOkHttpClient
import org.fnives.test.showcase.hilt.network.di.HiltNetworkModule
import org.fnives.test.showcase.hilt.network.session.NetworkSessionExpirationListener
import org.fnives.test.showcase.hilt.network.session.NetworkSessionLocalStorage
import javax.inject.Singleton
@Singleton
@Component(modules = [HiltNetworkModule::class, BindsBaseOkHttpClient::class])
interface TestNetworkComponent {
@Component.Builder
interface Builder {
@BindsInstance
fun setBaseUrl(baseUrl: String): Builder
@BindsInstance
fun setEnableLogging(enableLogging: Boolean): Builder
@BindsInstance
fun setNetworkSessionLocalStorage(storage: NetworkSessionLocalStorage): Builder
@BindsInstance
fun setNetworkSessionExpirationListener(listener: NetworkSessionExpirationListener): Builder
fun build(): TestNetworkComponent
}
fun inject(contentRemoteSourceImplTest: ContentRemoteSourceImplTest)
fun inject(sessionExpirationTest: SessionExpirationTest)
fun inject(loginRemoteSourceRefreshActionImplTest: LoginRemoteSourceRefreshActionImplTest)
fun inject(loginRemoteSourceTest: LoginRemoteSourceTest)
}

View file

@ -0,0 +1,4 @@
{
"accessToken": "login-access",
"refreshToken": "login-refresh"
}

View file

@ -0,0 +1,21 @@
package org.fnives.test.showcase.hilt.network.testutil
import org.fnives.test.showcase.network.mockserver.MockServerScenarioSetup
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
class MockServerScenarioSetupExtensions : BeforeEachCallback, AfterEachCallback {
lateinit var url: String
lateinit var mockServerScenarioSetup: MockServerScenarioSetup
override fun beforeEach(context: ExtensionContext?) {
mockServerScenarioSetup = MockServerScenarioSetup()
url = mockServerScenarioSetup.start(false)
}
override fun afterEach(context: ExtensionContext?) {
mockServerScenarioSetup.stop()
}
}