Milestones/ms1 (#16)

* Closes #11

* Closes #13

* Closes #17

* Closes #18

* Closes #19

* Closes #6

* Closes #3

* Closes #12

* Closes #15
This commit is contained in:
Melihcan Aksoy
2019-07-26 13:38:51 +02:00
committed by Melih Aksoy
parent 11889446cb
commit 625776609d
103 changed files with 4367 additions and 716 deletions

View File

@@ -8,6 +8,6 @@ import com.melih.repository.interactors.base.Result
*/
abstract class Repository {
internal abstract suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>>
internal abstract suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>>
internal abstract suspend fun getLaunchById(id: Long): Result<LaunchEntity>
}

View File

@@ -4,8 +4,10 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import com.melih.repository.DEFAULT_NAME
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Entity(tableName = "Launches")
@JsonClass(generateAdapter = true)
data class LaunchEntity(
@PrimaryKey val id: Long = 0L,
val name: String = DEFAULT_NAME,

View File

@@ -1,5 +1,8 @@
package com.melih.repository.entities
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LaunchesEntity(
val id: Long = 0L,
val launches: List<LaunchEntity> = listOf(),

View File

@@ -2,13 +2,16 @@ package com.melih.repository.entities
import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
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())
)
@JsonClass(generateAdapter = true)
data class PadEntity(
@ColumnInfo(name = "id_pad") val id: Long = 0L,
@ColumnInfo(name = "name_pad") val name: String = DEFAULT_NAME,

View File

@@ -3,7 +3,9 @@ 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.JsonClass
@JsonClass(generateAdapter = true)
data class MissionEntity(
@ColumnInfo(name = "id_mission") val id: Long = 0L,
@ColumnInfo(name = "name_mission") val name: String = DEFAULT_NAME,

View File

@@ -4,7 +4,9 @@ import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME
import com.melih.repository.EMPTY_STRING
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RocketEntity(
@ColumnInfo(name = "id_rocket") val id: Long = 0L,
@ColumnInfo(name = "name_rocket") val name: String = DEFAULT_NAME,

View File

@@ -2,9 +2,12 @@ package com.melih.repository.interactors
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.BaseInteractor
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.InteractorParameters
import com.melih.repository.interactors.base.Result
import com.melih.repository.sources.SourceManager
import com.melih.repository.interactors.base.Success
import com.melih.repository.sources.NetworkSource
import com.melih.repository.sources.PersistenceSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
@@ -12,13 +15,32 @@ import javax.inject.Inject
/**
* Gets next given number of launches
*/
class GetLaunchDetails @Inject constructor(
private val sourceManager: SourceManager
) : BaseInteractor<LaunchEntity, GetLaunchDetails.Params>() {
@UseExperimental(ExperimentalCoroutinesApi::class)
class GetLaunchDetails @Inject constructor() : BaseInteractor<LaunchEntity, GetLaunchDetails.Params>() {
@ExperimentalCoroutinesApi
override suspend fun run(collector: FlowCollector<Result<LaunchEntity>>, params: Params) {
collector.emit(sourceManager.getLaunchById(params.id))
@field:Inject
internal lateinit var networkSource: NetworkSource
@field:Inject
internal lateinit var persistenceSource: PersistenceSource
override suspend fun FlowCollector<Result<LaunchEntity>>.run(params: Params) {
val result = persistenceSource.getLaunchById(params.id)
if (result !is Success) {
when (val response = networkSource.getLaunchById(params.id)) {
// Save result and return again from persistence
is Success -> {
persistenceSource.saveLaunch(response.successData)
emit(persistenceSource.getLaunchById(params.id))
}
// Redirect failure as it is
is Failure -> emit(response)
}
} else {
emit(result)
}
}
data class Params(

View File

@@ -4,24 +4,45 @@ 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 com.melih.repository.interactors.base.Success
import com.melih.repository.sources.NetworkSource
import com.melih.repository.sources.PersistenceSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
const val DEFAULT_LAUNCHES_AMOUNT = 15
/**
* Gets next given number of launches
*/
class GetLaunches @Inject constructor(
private val sourceManager: SourceManager
) : BaseInteractor<List<LaunchEntity>, GetLaunches.Params>() {
@UseExperimental(ExperimentalCoroutinesApi::class)
class GetLaunches @Inject constructor() : BaseInteractor<List<LaunchEntity>, GetLaunches.Params>() {
@ExperimentalCoroutinesApi
override suspend fun run(collector: FlowCollector<Result<List<LaunchEntity>>>, params: Params) {
collector.emit(sourceManager.getNextLaunches(params.count))
@field:Inject
internal lateinit var networkSource: NetworkSource
@field:Inject
internal lateinit var persistenceSource: PersistenceSource
override suspend fun FlowCollector<Result<List<LaunchEntity>>>.run(params: Params) {
// Start network fetch - we're not handling state here to ommit them
networkSource
.getNextLaunches(params.count, params.page)
.also {
if (it is Success) {
persistenceSource.saveLaunches(it.successData)
emit(persistenceSource.getNextLaunches(params.count, params.page))
} else {
emit(it)
emit(persistenceSource.getNextLaunches(params.count, params.page))
}
}
}
data class Params(
val count: Int = 10
val count: Int = DEFAULT_LAUNCHES_AMOUNT,
val page: Int
) : InteractorParameters
}

View File

@@ -10,23 +10,22 @@ import kotlinx.coroutines.flow.flowOn
/**
* Base use case that wraps [suspending][suspend] [run] function with [flow][Flow] and returns it for later usage.
*/
@UseExperimental(ExperimentalCoroutinesApi::class)
abstract class BaseInteractor<T, in P : InteractorParameters> {
// region Abstractions
@ExperimentalCoroutinesApi
protected abstract suspend fun run(collector: FlowCollector<Result<T>>, params: P)
protected abstract suspend fun FlowCollector<Result<T>>.run(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)
flow<Result<T>> {
emit(State.Loading())
run(params)
emit(State.Loaded())
}.flowOn(Dispatchers.IO)
// endregion
}

View File

@@ -5,15 +5,14 @@ import com.melih.repository.R
/**
* [Result.Failure] reasons
* [Failure] reasons
*/
sealed class Reason(@StringRes val messageRes: Int) {
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)
}
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

@@ -1,45 +1,53 @@
package com.melih.repository.interactors.base
import kotlinx.coroutines.ExperimentalCoroutinesApi
/**
* Result class that wraps any [Success], [Failure] or [State] that can be generated by any derivation of [BaseInteractor]
*/
sealed class Result<out T> {
@UseExperimental(ExperimentalCoroutinesApi::class)
sealed class Result<out T>
// region Subclasses
// region Subclasses
class Success<out T>(val successData: T) : Result<T>()
class Failure(val errorData: Reason) : Result<Nothing>()
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
sealed class State : Result<Nothing>() {
class Loading : State()
class Loaded : State()
}
// endregion
// region Extensions
inline fun <T> Result<T>.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 <T> Result<T>.onSuccess(successBlock: (T) -> Unit): Result<T> {
if (this is Success)
successBlock(successData)
return this
}
inline fun <T> Result<T>.onFailure(errorBlock: (Reason) -> Unit): Result<T> {
if (this is Failure)
errorBlock(errorData)
return this
}
inline fun <T> Result<T>.onState(stateBlock: (State) -> Unit): Result<T> {
if (this is State)
stateBlock(this)
return this
}
// endregion

View File

@@ -5,15 +5,21 @@ import com.melih.repository.entities.LaunchesEntity
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for networking
*/
interface Api {
internal interface Api {
@GET("launch/next/{count}")
suspend fun getNextLaunches(@Path("count") count: Int): Response<LaunchesEntity>
suspend fun getNextLaunches(
@Path("count") count: Int,
@Query("offset") offset: Int
): Response<LaunchesEntity>
@GET("launch/{id}")
suspend fun getLaunchById(@Path("id") id: Long): Response<LaunchEntity>
suspend fun getLaunchById(
@Path("id") id: Long
): Response<LaunchEntity>
}

View File

@@ -9,9 +9,12 @@ import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class ApiImpl @Inject constructor() : Api {
internal const val TIMEOUT_DURATION = 7L
internal class ApiImpl @Inject constructor() : Api {
// region Properties
@@ -23,9 +26,12 @@ class ApiImpl @Inject constructor() : Api {
Retrofit.Builder()
.client(
OkHttpClient.Builder()
.connectTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
.addInterceptor(
HttpLoggingInterceptor()
.setLevel(HttpLoggingInterceptor.Level.BODY)
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
).build()
)
.addConverterFactory(MoshiConverterFactory.create(moshi))
@@ -35,9 +41,14 @@ class ApiImpl @Inject constructor() : Api {
}
// endregion
override suspend fun getNextLaunches(count: Int): Response<LaunchesEntity> =
service.getNextLaunches(count)
override suspend fun getNextLaunches(
count: Int,
offset: Int
): Response<LaunchesEntity> =
service.getNextLaunches(count, offset)
override suspend fun getLaunchById(id: Long): Response<LaunchEntity> =
override suspend fun getLaunchById(
id: Long
): Response<LaunchEntity> =
service.getLaunchById(id)
}

View File

@@ -1,6 +1,8 @@
package com.melih.repository.persistence
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.melih.repository.entities.LaunchEntity
@@ -24,7 +26,14 @@ const val DB_NAME = "LaunchesDB"
RocketConverter::class,
MissionConverter::class
)
abstract class LaunchesDatabase : RoomDatabase() {
internal abstract class LaunchesDatabase : RoomDatabase() {
abstract val launchesDao: LaunchesDao
companion object {
fun getInstance(ctx: Context) =
Room.databaseBuilder(ctx, LaunchesDatabase::class.java, DB_NAME)
.build()
}
internal abstract val launchesDao: LaunchesDao
}

View File

@@ -1,6 +1,7 @@
package com.melih.repository.persistence.converters
import com.melih.repository.entities.LocationEntity
import com.melih.repository.entities.LocationEntityJsonAdapter
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
@@ -9,5 +10,5 @@ import com.squareup.moshi.Moshi
*/
class LocationConverter : BaseConverter<LocationEntity>() {
override fun getAdapter(moshi: Moshi): JsonAdapter<LocationEntity> =
moshi.adapter(LocationEntity::class.java)
LocationEntityJsonAdapter(moshi)
}

View File

@@ -1,6 +1,7 @@
package com.melih.repository.persistence.converters
import com.melih.repository.entities.RocketEntity
import com.melih.repository.entities.RocketEntityJsonAdapter
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
@@ -9,5 +10,5 @@ import com.squareup.moshi.Moshi
*/
class RocketConverter : BaseConverter<RocketEntity>() {
override fun getAdapter(moshi: Moshi): JsonAdapter<RocketEntity> =
moshi.adapter(RocketEntity::class.java)
RocketEntityJsonAdapter(moshi)
}

View File

@@ -2,20 +2,20 @@ package com.melih.repository.persistence.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.melih.repository.entities.LaunchEntity
/**
* DAO for list of [launches][LaunchEntity]
*/
@Dao
abstract class LaunchesDao {
internal abstract class LaunchesDao {
// region Queries
@Query("SELECT * FROM Launches LIMIT :count")
abstract suspend fun getLaunches(count: Int): List<LaunchEntity>
@Query("SELECT * FROM Launches ORDER BY launchStartTime DESC LIMIT :count OFFSET :page*:count")
abstract suspend fun getLaunches(count: Int, page: Int): List<LaunchEntity>
@Query("SELECT * FROM Launches WHERE id=:id LIMIT 1")
abstract suspend fun getLaunchById(id: Long): LaunchEntity?
@@ -26,19 +26,10 @@ abstract class LaunchesDao {
// region Insertion
@Insert
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun saveLaunches(launches: List<LaunchEntity>)
@Insert
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun saveLaunch(launch: LaunchEntity)
// endregion
// region Transactions
@Transaction
open suspend fun updateLaunches(launches: List<LaunchEntity>) {
nukeLaunches()
saveLaunches(launches)
}
// endregion
}

View File

@@ -4,8 +4,15 @@ 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.DEFAULT_LAUNCHES_AMOUNT
import com.melih.repository.interactors.base.EmptyResultError
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.NetworkError
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.ResponseError
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.TimeoutError
import com.melih.repository.network.ApiImpl
import retrofit2.Response
import java.io.IOException
@@ -14,9 +21,9 @@ 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
* returning either [failure][Failure] with proper [reason][Reason] or [success][Success] with data
*/
class NetworkSource @Inject constructor(
internal class NetworkSource @Inject constructor(
private val apiImpl: ApiImpl,
private val networkInfoProvider: Provider<NetworkInfo>
) : Repository() {
@@ -31,8 +38,10 @@ class NetworkSource @Inject constructor(
// region Functions
override suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>> =
safeExecute(apiImpl::getNextLaunches, count) { entity ->
override suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>> =
safeExecute({
apiImpl.getNextLaunches(count, page * DEFAULT_LAUNCHES_AMOUNT)
}) { entity ->
entity.launches.map { launch ->
if (!launch.rocket.imageURL.isNotBlank()) {
launch.copy(
@@ -50,7 +59,9 @@ class NetworkSource @Inject constructor(
}
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
safeExecute(apiImpl::getLaunchById, id) {
safeExecute({
apiImpl.getLaunchById(id)
}) {
if (!it.rocket.imageURL.isNotBlank()) {
it.copy(
rocket = it.rocket.copy(
@@ -62,28 +73,27 @@ class NetworkSource @Inject constructor(
}
}
private suspend inline fun <T, P, R> safeExecute(
block: suspend (param: P) -> Response<T>,
param: P,
private suspend inline fun <T, R> safeExecute(
block: suspend () -> Response<T>,
transform: (T) -> R
) =
if (isNetworkConnected) {
try {
block(param).extractResponseBody(transform)
block().extractResponseBody(transform)
} catch (e: IOException) {
Result.Failure(Reason.TimeoutError())
Failure(TimeoutError())
}
} else {
Result.Failure(Reason.NetworkError())
Failure(NetworkError())
}
private inline fun <T, R> Response<T>.extractResponseBody(transform: (T) -> R) =
if (isSuccessful) {
body()?.let {
Result.Success(transform(it))
} ?: Result.Failure(Reason.EmptyResultError())
Success(transform(it))
} ?: Failure(EmptyResultError())
} else {
Result.Failure(Reason.ResponseError())
Failure(ResponseError())
}
private fun transformImageUrl(imageUrl: String, supportedSizes: IntArray) =

View File

@@ -1,28 +1,34 @@
package com.melih.repository.sources
import android.content.Context
import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
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
internal class PersistenceSource @Inject constructor(
ctx: Context
) : Repository() {
// region Functions
override suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>> =
private val launchesDatabase = LaunchesDatabase.getInstance(ctx)
override suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>> =
launchesDatabase
.launchesDao
.getLaunches(count)
.getLaunches(count, page)
.takeIf { it.isNotEmpty() }
?.run {
Result.Success(this)
} ?: Result.Failure(Reason.PersistenceEmpty())
Success(this)
} ?: Failure(PersistenceEmpty())
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
launchesDatabase
@@ -30,11 +36,11 @@ class PersistenceSource @Inject constructor(
.getLaunchById(id)
.takeIf { it != null }
?.run {
Result.Success(this)
} ?: Result.Failure(Reason.PersistenceEmpty())
Success(this)
} ?: Failure(PersistenceEmpty())
internal suspend fun saveLaunches(launches: List<LaunchEntity>) {
launchesDatabase.launchesDao.updateLaunches(launches)
launchesDatabase.launchesDao.saveLaunches(launches)
}
internal suspend fun saveLaunch(launch: LaunchEntity) {

View File

@@ -1,55 +0,0 @@
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

@@ -12,14 +12,13 @@ import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Test
import java.util.*
@UseExperimental(ExperimentalCoroutinesApi::class)
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
@@ -42,24 +41,23 @@ class BaseInteractorTest {
resultDeque.size shouldEqualTo 3
// Verify first item is Loading state
resultDeque.poll() shouldBeInstanceOf Result.State.Loading::class
resultDeque.poll() shouldBeInstanceOf 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
it shouldBeInstanceOf Success::class
(it as Success<Int>).successData shouldEqualTo 10
}
// Verify last item is Loaded state
resultDeque.poll() shouldBeInstanceOf Result.State.Loaded::class
resultDeque.poll() shouldBeInstanceOf 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))
override suspend fun FlowCollector<Result<Int>>.run(params: TestParams) {
emit(Success(params.testValue))
}
}

View File

@@ -12,11 +12,11 @@ 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 success = Success(number)
private val failure = Failure(GenericError())
private val state = State.Loading()
private val emptyStateBlock = spyk({ _: Result.State -> })
private val emptyStateBlock = spyk({ _: State -> })
private val emptyFailureBlock = spyk({ _: Reason -> })
private val emptySuccessBlock = spyk({ _: Int -> })
@@ -37,8 +37,8 @@ class ResultTest {
@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
reason shouldBeInstanceOf GenericError::class
(reason as GenericError).messageRes shouldEqualTo R.string.reason_generic
Unit
})
@@ -51,8 +51,8 @@ class ResultTest {
@Test
fun `State should only invoke stateBlock with correct state`() {
val actualSuccessBlock = spyk({ state: Result.State ->
state shouldBeInstanceOf Result.State.Loading::class
val actualSuccessBlock = spyk({ state: State ->
state shouldBeInstanceOf State.Loading::class
Unit
})

View File

@@ -4,8 +4,13 @@ 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.interactors.base.EmptyResultError
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.NetworkError
import com.melih.repository.interactors.base.ResponseError
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onSuccess
import com.melih.repository.network.ApiImpl
import io.mockk.coEvery
import io.mockk.every
@@ -19,6 +24,7 @@ import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import javax.inject.Provider
@UseExperimental(ExperimentalCoroutinesApi::class)
class NetworkSourceTest {
private val apiImpl = mockk<ApiImpl>(relaxed = true)
@@ -32,66 +38,62 @@ class NetworkSourceTest {
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)
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.NetworkError::class
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf 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
coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns false
runBlockingTest {
val result = source.getNextLaunches(1)
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.ResponseError::class
(it as Reason.ResponseError).messageRes shouldEqualTo R.string.reason_response
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf ResponseError::class
(it as 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
coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).body() } returns null
runBlockingTest {
val result = source.getNextLaunches(1)
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.EmptyResultError::class
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf 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)))
coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).body() } returns LaunchesEntity(launches = listOf(LaunchEntity(id = 1013)))
runBlockingTest {
val result = source.getNextLaunches(1)
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
result shouldBeInstanceOf Success::class
result.onSuccess {
it shouldBeInstanceOf List::class
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013

View File

@@ -1,51 +1,68 @@
package com.melih.repository.sources
import android.content.Context
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onSuccess
import com.melih.repository.persistence.LaunchesDatabase
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class PersistanceSourceTest {
private val ctx = mockk<Context>(relaxed = true)
private val dbImplementation = mockk<LaunchesDatabase>(relaxed = true)
private val source = spyk(PersistenceSource(dbImplementation))
private val source = spyk(PersistenceSource(ctx))
private val scope = CoroutineScope(Dispatchers.IO)
@BeforeEach
fun setup() {
mockkObject(LaunchesDatabase)
every { LaunchesDatabase.getInstance(ctx) } returns 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()
coEvery { dbImplementation.launchesDao.getLaunches(any(), any()) } returns emptyList()
val result = source.getNextLaunches(10)
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.PersistenceEmpty::class
scope.launch {
val result = source.getNextLaunches(10, 0)
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf 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))
coEvery { dbImplementation.launchesDao.getLaunches(any(), any()) } returns listOf(LaunchEntity(id = 1013))
val result = source.getNextLaunches(10)
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
scope.launch {
val result = source.getNextLaunches(10, 0)
result shouldBeInstanceOf Success::class
result.onSuccess {
it.isEmpty() shouldBe false
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013

View File

@@ -1,141 +0,0 @@
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
}
}
}
}
}