Initial commit

This commit is contained in:
Melih Aksoy
2019-07-01 15:56:55 +02:00
commit 6029facf73
143 changed files with 6891 additions and 0 deletions

1
repository/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

30
repository/build.gradle Normal file
View File

@@ -0,0 +1,30 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/module.gradle"
android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$rootProject.projectDir/reports/room".toString()]
}
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation libraries.coroutines
implementation libraries.retrofit
implementation libraries.room
implementation libraries.moshiKotlin
implementation libraries.okHttpLogger
kapt annotationProcessors.roomCompiler
testImplementation testLibraries.coroutinesCore
testImplementation testLibraries.coroutinesTest
}

0
repository/proguard-rules.pro vendored Normal file
View File

View File

@@ -0,0 +1,2 @@
<manifest package="com.melih.repository"/>

View File

@@ -0,0 +1,5 @@
package com.melih.repository
const val DEFAULT_NAME = "Default name"
const val EMPTY_STRING = ""
const val DEFAULT_IMAGE_SIZE = 480

View File

@@ -0,0 +1,13 @@
package com.melih.repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Result
/**
* Abstract class to create contract in sources to seperate low level business logic from build and return type
*/
abstract class Repository {
internal abstract suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>>
internal abstract suspend fun getLaunchById(id: Long): Result<LaunchEntity>
}

View File

@@ -0,0 +1,17 @@
package com.melih.repository.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.melih.repository.DEFAULT_NAME
import com.squareup.moshi.Json
@Entity(tableName = "Launches")
data class LaunchEntity(
@PrimaryKey val id: Long = 0L,
val name: String = DEFAULT_NAME,
@field:Json(name = "wsstamp") val launchStartTime: Long = 0L,
@field:Json(name = "westamp") val launchEndTime: Long = 0L,
val location: LocationEntity = LocationEntity(),
val rocket: RocketEntity = RocketEntity(),
val missions: List<MissionEntity> = listOf(MissionEntity())
)

View File

@@ -0,0 +1,9 @@
package com.melih.repository.entities
data class LaunchesEntity(
val id: Long = 0L,
val launches: List<LaunchEntity> = listOf(),
val total: Int = 0,
val offset: Int = 0,
val count: Int = 0
)

View File

@@ -0,0 +1,17 @@
package com.melih.repository.entities
import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME
data class LocationEntity(
@ColumnInfo(name = "id_location") val id: Long = 0L,
@ColumnInfo(name = "name_location") val name: String = DEFAULT_NAME,
val pads: List<PadEntity> = listOf(PadEntity())
)
data class PadEntity(
@ColumnInfo(name = "id_pad") val id: Long = 0L,
@ColumnInfo(name = "name_pad") val name: String = DEFAULT_NAME,
val lat: Long = 0L,
val long: Long = 0L
)

View File

@@ -0,0 +1,12 @@
package com.melih.repository.entities
import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME
import com.melih.repository.EMPTY_STRING
data class MissionEntity(
@ColumnInfo(name = "id_mission") val id: Long = 0L,
@ColumnInfo(name = "name_mission") val name: String = DEFAULT_NAME,
val description: String = EMPTY_STRING,
val typeName: String = EMPTY_STRING
)

View File

@@ -0,0 +1,14 @@
package com.melih.repository.entities
import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME
import com.melih.repository.EMPTY_STRING
import com.squareup.moshi.Json
data class RocketEntity(
@ColumnInfo(name = "id_rocket") val id: Long = 0L,
@ColumnInfo(name = "name_rocket") val name: String = DEFAULT_NAME,
@field:Json(name = "familyname") val familyName: String = DEFAULT_NAME,
val imageSizes: IntArray = intArrayOf(),
val imageURL: String = EMPTY_STRING
)

View File

@@ -0,0 +1,27 @@
package com.melih.repository.interactors
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.BaseInteractor
import com.melih.repository.interactors.base.InteractorParameters
import com.melih.repository.interactors.base.Result
import com.melih.repository.sources.SourceManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
/**
* Gets next given number of launches
*/
class GetLaunchDetails @Inject constructor(
private val sourceManager: SourceManager
) : BaseInteractor<LaunchEntity, GetLaunchDetails.Params>() {
@ExperimentalCoroutinesApi
override suspend fun run(collector: FlowCollector<Result<LaunchEntity>>, params: Params) {
collector.emit(sourceManager.getLaunchById(params.id))
}
data class Params(
val id: Long
) : InteractorParameters
}

View File

@@ -0,0 +1,27 @@
package com.melih.repository.interactors
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.BaseInteractor
import com.melih.repository.interactors.base.InteractorParameters
import com.melih.repository.interactors.base.Result
import com.melih.repository.sources.SourceManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
/**
* Gets next given number of launches
*/
class GetLaunches @Inject constructor(
private val sourceManager: SourceManager
) : BaseInteractor<List<LaunchEntity>, GetLaunches.Params>() {
@ExperimentalCoroutinesApi
override suspend fun run(collector: FlowCollector<Result<List<LaunchEntity>>>, params: Params) {
collector.emit(sourceManager.getNextLaunches(params.count))
}
data class Params(
val count: Int = 10
) : InteractorParameters
}

View File

@@ -0,0 +1,41 @@
package com.melih.repository.interactors.base
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
/**
* Base use case that wraps [suspending][suspend] [run] function with [flow][Flow] and returns it for later usage.
*/
abstract class BaseInteractor<T, in P : InteractorParameters> {
// region Abstractions
@ExperimentalCoroutinesApi
protected abstract suspend fun run(collector: FlowCollector<Result<T>>, params: P)
// endregion
// region Functions
@ExperimentalCoroutinesApi
operator fun invoke(params: P) =
flow<Result<T>> {
emit(Result.State.Loading())
run(this, params)
emit(Result.State.Loaded())
}.flowOn(Dispatchers.IO)
// endregion
}
/**
* Contract for parameter classes
*/
interface InteractorParameters
/**
* Symbolizes absence of parameters for an [interactor][BaseInteractor]
*/
class None : Any(), InteractorParameters

View File

@@ -0,0 +1,19 @@
package com.melih.repository.interactors.base
import androidx.annotation.StringRes
import com.melih.repository.R
/**
* [Result.Failure] reasons
*/
sealed class Reason(@StringRes val messageRes: Int) {
class NetworkError : Reason(R.string.reason_network)
class EmptyResultError : Reason(R.string.reason_empty_body)
class GenericError : Reason(R.string.reason_generic)
class ResponseError : Reason(R.string.reason_response)
class TimeoutError : Reason(R.string.reason_timeout)
class PersistenceEmpty : Reason(R.string.reason_persistance_empty)
class NoNetworkPersistenceEmpty : Reason(R.string.reason_no_network_persistance_empty)
}

View File

@@ -0,0 +1,45 @@
package com.melih.repository.interactors.base
/**
* Result class that wraps any [Success], [Failure] or [State] that can be generated by any derivation of [BaseInteractor]
*/
sealed class Result<out T> {
// region Subclasses
class Success<out T>(val successData: T) : Result<T>()
class Failure(val errorData: Reason) : Result<Nothing>()
sealed class State : Result<Nothing>() {
class Loading : State()
class Loaded : State()
}
// endregion
// region Functions
inline fun handle(stateBlock: (State) -> Unit, failureBlock: (Reason) -> Unit, successBlock: (T) -> Unit) {
when (this) {
is Success -> successBlock(successData)
is Failure -> failureBlock(errorData)
is State -> stateBlock(this)
}
}
inline fun handleSuccess(successBlock: (T) -> Unit) {
if (this is Success)
successBlock(successData)
}
inline fun handleFailure(errorBlock: (Reason) -> Unit) {
if (this is Failure)
errorBlock(errorData)
}
inline fun handleState(stateBlock: (State) -> Unit) {
if (this is State)
stateBlock(this)
}
// endregion
}

View File

@@ -0,0 +1,19 @@
package com.melih.repository.network
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
/**
* Retrofit interface for networking
*/
interface Api {
@GET("launch/next/{count}")
suspend fun getNextLaunches(@Path("count") count: Int): Response<LaunchesEntity>
@GET("launch/{id}")
suspend fun getLaunchById(@Path("id") id: Long): Response<LaunchEntity>
}

View File

@@ -0,0 +1,43 @@
package com.melih.repository.network
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Inject
class ApiImpl @Inject constructor() : Api {
// region Properties
private val service by lazy {
val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
Retrofit.Builder()
.client(
OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor()
.setLevel(HttpLoggingInterceptor.Level.BODY)
).build()
)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.baseUrl("https://launchlibrary.net/1.4/")
.build()
.create(Api::class.java)
}
// endregion
override suspend fun getNextLaunches(count: Int): Response<LaunchesEntity> =
service.getNextLaunches(count)
override suspend fun getLaunchById(id: Long): Response<LaunchEntity> =
service.getLaunchById(id)
}

View File

@@ -0,0 +1,30 @@
package com.melih.repository.persistence
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.persistence.converters.LocationConverter
import com.melih.repository.persistence.converters.MissionConverter
import com.melih.repository.persistence.converters.RocketConverter
import com.melih.repository.persistence.dao.LaunchesDao
const val DB_NAME = "LaunchesDB"
/**
* DB that manages launches
*/
@Database(
entities = [LaunchEntity::class],
exportSchema = true,
version = 1
)
@TypeConverters(
LocationConverter::class,
RocketConverter::class,
MissionConverter::class
)
abstract class LaunchesDatabase : RoomDatabase() {
abstract val launchesDao: LaunchesDao
}

View File

@@ -0,0 +1,29 @@
package com.melih.repository.persistence.converters
import androidx.room.TypeConverter
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
/**
* Base converter for reduced boilerplate code
*/
abstract class BaseConverter<T> {
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
abstract fun getAdapter(moshi: Moshi): JsonAdapter<T>
// region Functions
@TypeConverter
fun convertFrom(item: T) =
getAdapter(moshi).toJson(item)
@TypeConverter
fun convertTo(string: String) =
getAdapter(moshi).fromJson(string)
// endregion
}

View File

@@ -0,0 +1,29 @@
package com.melih.repository.persistence.converters
import androidx.room.TypeConverter
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
/**
* Base converter for reduced boilerplate code
*/
abstract class BaseListConverter<T> {
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
abstract fun getAdapter(moshi: Moshi): JsonAdapter<List<T>>
// region Functions
@TypeConverter
fun convertFrom(items: List<T>) =
getAdapter(moshi).toJson(items)
@TypeConverter
fun convertTo(string: String): List<T>? =
getAdapter(moshi).fromJson(string)
// endregion
}

View File

@@ -0,0 +1,13 @@
package com.melih.repository.persistence.converters
import com.melih.repository.entities.LocationEntity
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
/**
* Converts [location][LocationEntity]
*/
class LocationConverter : BaseConverter<LocationEntity>() {
override fun getAdapter(moshi: Moshi): JsonAdapter<LocationEntity> =
moshi.adapter(LocationEntity::class.java)
}

View File

@@ -0,0 +1,20 @@
package com.melih.repository.persistence.converters
import com.melih.repository.entities.MissionEntity
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
/**
* Converts [mission][MissionEntity]
*/
class MissionConverter : BaseListConverter<MissionEntity>() {
override fun getAdapter(moshi: Moshi): JsonAdapter<List<MissionEntity>> =
moshi.adapter(
Types.newParameterizedType(
List::class.java,
MissionEntity::class.java
)
)
}

View File

@@ -0,0 +1,13 @@
package com.melih.repository.persistence.converters
import com.melih.repository.entities.RocketEntity
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
/**
* Converts [rocket][RocketEntity]
*/
class RocketConverter : BaseConverter<RocketEntity>() {
override fun getAdapter(moshi: Moshi): JsonAdapter<RocketEntity> =
moshi.adapter(RocketEntity::class.java)
}

View File

@@ -0,0 +1,44 @@
package com.melih.repository.persistence.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import com.melih.repository.entities.LaunchEntity
/**
* DAO for list of [launches][LaunchEntity]
*/
@Dao
abstract class LaunchesDao {
// region Queries
@Query("SELECT * FROM Launches LIMIT :count")
abstract suspend fun getLaunches(count: Int): List<LaunchEntity>
@Query("SELECT * FROM Launches WHERE id=:id LIMIT 1")
abstract suspend fun getLaunchById(id: Long): LaunchEntity?
@Query("DELETE FROM Launches")
abstract suspend fun nukeLaunches()
// endregion
// region Insertion
@Insert
abstract suspend fun saveLaunches(launches: List<LaunchEntity>)
@Insert
abstract suspend fun saveLaunch(launch: LaunchEntity)
// endregion
// region Transactions
@Transaction
open suspend fun updateLaunches(launches: List<LaunchEntity>) {
nukeLaunches()
saveLaunches(launches)
}
// endregion
}

View File

@@ -0,0 +1,106 @@
package com.melih.repository.sources
import android.net.NetworkInfo
import com.melih.repository.DEFAULT_IMAGE_SIZE
import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import com.melih.repository.network.ApiImpl
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Provider
/**
* NetworkSource for fetching results using api and wrapping them as contracted in [repository][Repository],
* returning either [failure][Result.Failure] with proper [reason][Reason] or [success][Result.Success] with data
*/
class NetworkSource @Inject constructor(
private val apiImpl: ApiImpl,
private val networkInfoProvider: Provider<NetworkInfo>
) : Repository() {
// region Properties
private val isNetworkConnected: Boolean
get() {
val networkInfo = networkInfoProvider.get()
return networkInfo != null && networkInfo.isConnected
}
// endregion
// region Functions
override suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>> =
safeExecute(apiImpl::getNextLaunches, count) { entity ->
entity.launches.map { launch ->
if (!launch.rocket.imageURL.isNotBlank()) {
launch.copy(
rocket = launch.rocket.copy(
imageURL = transformImageUrl(
launch.rocket.imageURL,
launch.rocket.imageSizes
)
)
)
} else {
launch
}
}
}
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
safeExecute(apiImpl::getLaunchById, id) {
if (!it.rocket.imageURL.isNotBlank()) {
it.copy(
rocket = it.rocket.copy(
imageURL = transformImageUrl(it.rocket.imageURL, it.rocket.imageSizes)
)
)
} else {
it
}
}
private suspend inline fun <T, P, R> safeExecute(
block: suspend (param: P) -> Response<T>,
param: P,
transform: (T) -> R
) =
if (isNetworkConnected) {
try {
block(param).extractResponseBody(transform)
} catch (e: IOException) {
Result.Failure(Reason.TimeoutError())
}
} else {
Result.Failure(Reason.NetworkError())
}
private inline fun <T, R> Response<T>.extractResponseBody(transform: (T) -> R) =
if (isSuccessful) {
body()?.let {
Result.Success(transform(it))
} ?: Result.Failure(Reason.EmptyResultError())
} else {
Result.Failure(Reason.ResponseError())
}
private fun transformImageUrl(imageUrl: String, supportedSizes: IntArray) =
try {
val urlSplit = imageUrl.split("_")
val url = urlSplit[0]
val format = urlSplit[1].split(".")[1]
var requestedSize = DEFAULT_IMAGE_SIZE
if (!supportedSizes.contains(requestedSize)) {
requestedSize = supportedSizes.last { it < requestedSize }
}
"${url}_$requestedSize.$format"
} catch (e: Exception) {
imageUrl
}
// endregion
}

View File

@@ -0,0 +1,44 @@
package com.melih.repository.sources
import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import com.melih.repository.persistence.LaunchesDatabase
import javax.inject.Inject
/**
* Persistance source using Room database to save / read objects for SST - offline usage
*/
class PersistenceSource @Inject constructor(
private val launchesDatabase: LaunchesDatabase
) : Repository() {
// region Functions
override suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>> =
launchesDatabase
.launchesDao
.getLaunches(count)
.takeIf { it.isNotEmpty() }
?.run {
Result.Success(this)
} ?: Result.Failure(Reason.PersistenceEmpty())
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
launchesDatabase
.launchesDao
.getLaunchById(id)
.takeIf { it != null }
?.run {
Result.Success(this)
} ?: Result.Failure(Reason.PersistenceEmpty())
internal suspend fun saveLaunches(launches: List<LaunchEntity>) {
launchesDatabase.launchesDao.updateLaunches(launches)
}
internal suspend fun saveLaunch(launch: LaunchEntity) {
launchesDatabase.launchesDao.saveLaunch(launch)
}
// endregion
}

View File

@@ -0,0 +1,55 @@
package com.melih.repository.sources
import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import javax.inject.Inject
/**
* Manages SST by using network & persistance sources
*/
class SourceManager @Inject constructor(
private val networkSource: NetworkSource,
private val persistenceSource: PersistenceSource
) : Repository() {
// region Functions
override suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>> {
networkSource
.getNextLaunches(count)
.takeIf { it is Result.Success }
?.let {
persistenceSource.saveLaunches((it as Result.Success).successData)
}
return persistenceSource
.getNextLaunches(count)
.takeIf {
it is Result.Success && it.successData.isNotEmpty()
}
?: Result.Failure(Reason.NoNetworkPersistenceEmpty())
}
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> {
val result =
persistenceSource
.getLaunchById(id)
return if (result is Result.Failure) {
networkSource
.getLaunchById(id)
.takeIf { it is Result.Success }
?.let {
persistenceSource.saveLaunch((it as Result.Success).successData)
}
persistenceSource
.getLaunchById(id)
} else {
result
}
}
// endregion
}

View File

@@ -0,0 +1,9 @@
<resources>
<string name="reason_network">Network error</string>
<string name="reason_empty_body">Response is empty</string>
<string name="reason_generic">Something went wrong</string>
<string name="reason_response">Woops, seems we got a server error</string>
<string name="reason_timeout">Server timed out</string>
<string name="reason_persistance_empty">There are no saved launches</string>
<string name="reason_no_network_persistance_empty">Seems there are no data and network</string>
</resources>

View File

@@ -0,0 +1,67 @@
package com.melih.repository.interactors.base
import io.mockk.coVerify
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.toCollection
import kotlinx.coroutines.runBlocking
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Test
import java.util.*
class BaseInteractorTest {
val testInteractor = spyk(TestInteractor())
val testParams = TestParams()
@Test
@ExperimentalCoroutinesApi
fun `BaseInteractor should send states and items emmited by run`() {
// Using run blocking due to threading problems in runBlockingTest
// See https://github.com/Kotlin/kotlinx.coroutines/issues/1204
runBlocking {
// Get result by invoking
val result = testInteractor(testParams)
// Verify we invoked interactor exactly once
coVerify(exactly = 1) { testInteractor.invoke(any()) }
// Verify result is type of Flow
result shouldBeInstanceOf Flow::class
// This will actually collec the flow
val resultDeque = ArrayDeque<Result<Int>>()
result.toCollection(resultDeque)
// We sent exactly 3 items, verify size
resultDeque.size shouldEqualTo 3
// Verify first item is Loading state
resultDeque.poll() shouldBeInstanceOf Result.State.Loading::class
// Verify second item is Success, with default value we set below in TestParams class
resultDeque.poll().also {
it shouldBeInstanceOf Result.Success::class
(it as Result.Success<Int>).successData shouldEqualTo 10
}
// Verify last item is Loaded state
resultDeque.poll() shouldBeInstanceOf Result.State.Loaded::class
}
}
inner class TestInteractor : BaseInteractor<Int, TestParams>() {
@ExperimentalCoroutinesApi
override suspend fun run(collector: FlowCollector<Result<Int>>, params: TestParams) {
collector.emit(Result.Success(params.testValue))
}
}
data class TestParams(val testValue: Int = 10) : InteractorParameters
}

View File

@@ -0,0 +1,65 @@
package com.melih.repository.interactors.base
import com.melih.repository.R
import io.mockk.called
import io.mockk.spyk
import io.mockk.verify
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Test
class ResultTest {
private val number = 10
private val success = Result.Success(number)
private val failure = Result.Failure(Reason.GenericError())
private val state = Result.State.Loading()
private val emptyStateBlock = spyk({ _: Result.State -> })
private val emptyFailureBlock = spyk({ _: Reason -> })
private val emptySuccessBlock = spyk({ _: Int -> })
@Test
fun `Success should only invoke successBlock with correct data`() {
val actualSuccessBlock = spyk({ data: Int ->
data shouldEqualTo number
Unit
})
success.handle(emptyStateBlock, emptyFailureBlock, actualSuccessBlock)
verify { emptyStateBlock wasNot called }
verify { emptyFailureBlock wasNot called }
verify(exactly = 1) { actualSuccessBlock.invoke(any()) }
}
@Test
fun `Failure should only invoke failureBlock with correct error`() {
val actualFailureBlock = spyk({ reason: Reason ->
reason shouldBeInstanceOf Reason.GenericError::class
(reason as Reason.GenericError).messageRes shouldEqualTo R.string.reason_generic
Unit
})
failure.handle(emptyStateBlock, actualFailureBlock, emptySuccessBlock)
verify { emptySuccessBlock wasNot called }
verify { emptyStateBlock wasNot called }
verify(exactly = 1) { actualFailureBlock.invoke(any()) }
}
@Test
fun `State should only invoke stateBlock with correct state`() {
val actualSuccessBlock = spyk({ state: Result.State ->
state shouldBeInstanceOf Result.State.Loading::class
Unit
})
state.handle(actualSuccessBlock, emptyFailureBlock, emptySuccessBlock)
verify { emptySuccessBlock wasNot called }
verify { emptyFailureBlock wasNot called }
verify(exactly = 1) { actualSuccessBlock.invoke(any()) }
}
}

View File

@@ -0,0 +1,102 @@
package com.melih.repository.sources
import android.net.NetworkInfo
import com.melih.repository.R
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import com.melih.repository.network.ApiImpl
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import javax.inject.Provider
class NetworkSourceTest {
private val apiImpl = mockk<ApiImpl>(relaxed = true)
private val networkInfoProvider = mockk<Provider<NetworkInfo>>(relaxed = true) {
every { get() } returns mockk(relaxed = true)
}
private val source = spyk(NetworkSource(apiImpl, networkInfoProvider))
@Nested
inner class GetNextLaunches {
@Test
@ExperimentalCoroutinesApi
fun `should return network error when internet is not connected`() {
every { networkInfoProvider.get().isConnected } returns false
runBlockingTest {
val result = source.getNextLaunches(1)
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.NetworkError::class
}
}
}
@Test
@ExperimentalCoroutinesApi
fun `should return response error when it is not successful`() {
every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any()).isSuccessful } returns false
runBlockingTest {
val result = source.getNextLaunches(1)
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.ResponseError::class
(it as Reason.ResponseError).messageRes shouldEqualTo R.string.reason_response
}
}
}
@Test
@ExperimentalCoroutinesApi
fun `should return empty result error when body is null`() {
every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any()).body() } returns null
runBlockingTest {
val result = source.getNextLaunches(1)
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.EmptyResultError::class
}
}
}
@Test
@ExperimentalCoroutinesApi
fun `should return success with data if execution is successful`() {
every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any()).body() } returns LaunchesEntity(launches = listOf(LaunchEntity(id = 1013)))
runBlockingTest {
val result = source.getNextLaunches(1)
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it shouldBeInstanceOf List::class
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
}
}

View File

@@ -0,0 +1,56 @@
package com.melih.repository.sources
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import com.melih.repository.persistence.LaunchesDatabase
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class PersistanceSourceTest {
private val dbImplementation = mockk<LaunchesDatabase>(relaxed = true)
private val source = spyk(PersistenceSource(dbImplementation))
@Nested
inner class GetNextLaunches {
@Test
@ExperimentalCoroutinesApi
fun `should return persistance empty error when db is empty`() {
runBlockingTest {
coEvery { dbImplementation.launchesDao.getLaunches(any()) } returns emptyList()
val result = source.getNextLaunches(10)
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.PersistenceEmpty::class
}
}
}
@Test
@ExperimentalCoroutinesApi
fun `should return success with data if db is not empty`() {
runBlockingTest {
coEvery { dbImplementation.launchesDao.getLaunches(any()) } returns listOf(LaunchEntity(id = 1013))
val result = source.getNextLaunches(10)
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it.isEmpty() shouldBe false
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
}
}

View File

@@ -0,0 +1,141 @@
package com.melih.repository.sources
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import io.mockk.called
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runBlockingTest
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class SourceManagerTest {
private val networkSource = mockk<NetworkSource>(relaxed = true)
private val persistenceSource = mockk<PersistenceSource>(relaxed = true)
private val sourceManager = spyk(SourceManager(networkSource, persistenceSource))
@Nested
inner class GetNextLaunches {
@Test
@ExperimentalCoroutinesApi
fun `should try to fetch, save and return result from persistance`() {
runBlockingTest {
val amount = 10
coEvery { networkSource.getNextLaunches(any()) } returns Result.Success(listOf(LaunchEntity(id = 1012)))
coEvery { persistenceSource.getNextLaunches(any()) } returns Result.Success(listOf(LaunchEntity(id = 1013)))
val result = sourceManager.getNextLaunches(amount)
coVerifyOrder {
networkSource.getNextLaunches(any())
persistenceSource.saveLaunches(any())
persistenceSource.getNextLaunches(any())
}
coVerify(exactly = 1) { networkSource.getNextLaunches(any()) }
coVerify(exactly = 1) { persistenceSource.saveLaunches(any()) }
coVerify(exactly = 1) { persistenceSource.getNextLaunches(any()) }
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
@Test
@ExperimentalCoroutinesApi
fun `should not save response if fetching was failure and return result from persistance`() {
runBlockingTest {
val amount = 10
coEvery { networkSource.getNextLaunches(any()) } returns Result.Failure(Reason.NetworkError())
coEvery { persistenceSource.getNextLaunches(any()) } returns Result.Success(listOf(LaunchEntity(id = 1013)))
val result = sourceManager.getNextLaunches(amount)
coVerify { persistenceSource.saveLaunches(any()) wasNot called }
coVerify(exactly = 1) { persistenceSource.getNextLaunches(any()) }
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
@Test
@ExperimentalCoroutinesApi
fun `should return failure if network and persistance fails`() {
runBlockingTest {
val amount = 10
coEvery { networkSource.getNextLaunches(any()) } returns Result.Failure(Reason.NetworkError())
coEvery { persistenceSource.getNextLaunches(any()) } returns Result.Failure(Reason.PersistenceEmpty())
val result = sourceManager.getNextLaunches(amount)
coVerify { persistenceSource.saveLaunches(any()) wasNot called }
coVerify(exactly = 1) { persistenceSource.getNextLaunches(any()) }
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.NoNetworkPersistenceEmpty::class
}
}
}
}
@Nested
inner class GetLaunchDetails {
@Test
fun `should return result from persistance immediately if it's found`() {
runBlocking {
coEvery { persistenceSource.getLaunchById(any()) } returns Result.Success(LaunchEntity(id = 1013))
val result = sourceManager.getLaunchById(1)
coVerify { networkSource.getLaunchById(any()) wasNot called }
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it.id shouldEqualTo 1013
}
}
}
@Test
fun `should fetch result from network if it's not found in persistance`() {
runBlocking {
coEvery {
persistenceSource.getLaunchById(any())
} returns Result.Failure(Reason.PersistenceEmpty()) andThen Result.Success(LaunchEntity(id = 1013))
coEvery { networkSource.getLaunchById(any()) } returns Result.Success(LaunchEntity(id = 1013))
val result = sourceManager.getLaunchById(1)
coVerify(exactly = 1) { networkSource.getLaunchById(any()) }
coVerify(exactly = 1) { persistenceSource.saveLaunch(any()) }
coVerify(exactly = 2) { persistenceSource.getLaunchById(any()) }
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it.id shouldEqualTo 1013
}
}
}
}
}