initial commit

This commit is contained in:
Gergely Hegedus 2021-04-07 21:12:10 +03:00
parent 85ef73b2ba
commit 90a9426b7d
221 changed files with 7611 additions and 0 deletions

View file

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

View file

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

View file

@ -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<LoginResponse>
@PUT("login/{token}")
suspend fun refreshToken(@Path("token") token: String): LoginResponse
}

View file

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

View file

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

View file

@ -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()
}

View file

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

View file

@ -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<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.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?
)

View file

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

View file

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

View file

@ -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<Module> =
sequenceOf(
loginModule(),
contentModule(),
sessionlessNetworkingModule(baseUrl, enableLogging),
sessionNetworkingModule(networkSessionLocalStorageProvider, networkSessionExpirationListenerProvider)
)
private fun loginModule() = module {
factory { LoginRemoteSourceImpl(get(), get()) }
factory<LoginRemoteSource> { get<LoginRemoteSourceImpl>() }
factory { LoginErrorConverter() }
factory { get<Retrofit>(sessionless).create(LoginService::class.java) }
}
private fun contentModule() = module {
factory { get<Retrofit>(session).create(ContentService::class.java) }
factory { ContentRemoteSourceImpl(get()) }
factory<ContentRemoteSource> { get<ContentRemoteSourceImpl>() }
}
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<MoshiConverterFactory>())
.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<OkHttpClient>(sessionless)
.newBuilder()
.authenticator(get<SessionAuthenticator>())
.addInterceptor(AuthenticationHeaderInterceptor(get()))
.build()
}
single(qualifier = session) { get<Retrofit>(sessionless).newBuilder().client(get(session)).build() }
}
private val session = StringQualifier("SESSION-NETWORKING")
private val sessionless = StringQualifier("SESSIONLESS-NETWORKING")

View file

@ -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()))
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <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,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())
}

View file

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

View file

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

View file

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

View file

@ -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<LoginRemoteSourceImpl>()
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) }
}
}
}

View file

@ -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<LoginRemoteSource>()
@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")) }
}
}
}

View file

@ -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() }
}
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}