Skip to content

Commit

Permalink
create a dirty favorite tools sync task
Browse files Browse the repository at this point in the history
  • Loading branch information
frett committed Oct 2, 2023
1 parent fbc6546 commit 0ed9873
Show file tree
Hide file tree
Showing 5 changed files with 315 additions and 6 deletions.
6 changes: 6 additions & 0 deletions library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ package org.cru.godtools.api

import org.ccci.gto.android.common.jsonapi.model.JsonApiObject
import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams
import org.ccci.gto.android.common.jsonapi.retrofit2.model.JsonApiRetrofitObject
import org.cru.godtools.model.User
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.QueryMap

internal const val PATH_USER = "users/me"

interface UserApi {
@GET(PATH_USER)
suspend fun getUser(@QueryMap params: JsonApiParams = JsonApiParams()): Response<JsonApiObject<User>>

@PATCH(PATH_USER)
suspend fun updateUser(@Body user: JsonApiRetrofitObject<User>): Response<JsonApiObject<User>>
}
8 changes: 4 additions & 4 deletions library/model/src/main/kotlin/org/cru/godtools/model/User.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,14 @@ import org.ccci.gto.android.common.jsonapi.annotation.JsonApiId
import org.ccci.gto.android.common.jsonapi.annotation.JsonApiIgnore
import org.ccci.gto.android.common.jsonapi.annotation.JsonApiType

private const val JSON_API_TYPE = "user"

private const val JSON_SSO_GUID = "sso-guid"
private const val JSON_NAME = "name"
private const val JSON_GIVEN_NAME = "given-name"
private const val JSON_FAMILY_NAME = "family-name"
private const val JSON_EMAIL = "email"
private const val JSON_CREATED_AT = "created-at"
private const val JSON_INITIAL_FAVORITE_TOOLS_SYNCED = "attr-initial-favorite-tools-synced"

@JsonApiType(JSON_API_TYPE)
@JsonApiType(User.JSONAPI_TYPE)
data class User @JvmOverloads constructor(
@JsonApiId
val id: String = "",
Expand All @@ -42,6 +39,9 @@ data class User @JvmOverloads constructor(
val isInitialFavoriteToolsSynced: Boolean = false,
) {
companion object {
const val JSONAPI_TYPE = "user"

const val JSON_INITIAL_FAVORITE_TOOLS_SYNCED = "attr-initial-favorite-tools-synced"
const val JSON_FAVORITE_TOOLS = "favorite-tools"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ internal class SyncRepository @Inject constructor(
return setOfNotNull(tool.code) + processIncludes(tool, includes)
}

private suspend fun storeFavoriteTools(tools: List<Tool>, includes: Includes) {
suspend fun storeFavoriteTools(tools: List<Tool>, includes: Includes = Includes()) {
storeTools(tools, includes = includes)
toolsRepository.storeFavoriteToolsFromSync(tools)
}
Expand Down Expand Up @@ -129,7 +129,7 @@ internal class SyncRepository @Inject constructor(
// endregion Translations

// region User
suspend fun storeUser(user: User, includes: Includes) {
suspend fun storeUser(user: User, includes: Includes = Includes()) {
userRepository.storeUserFromSync(user)

if (user.isInitialFavoriteToolsSynced && includes.include(User.JSON_FAVORITE_TOOLS)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package org.cru.godtools.sync.task

import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.ccci.gto.android.common.jsonapi.JsonApiConverter
import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams
import org.ccci.gto.android.common.jsonapi.retrofit2.model.JsonApiRetrofitObject
import org.cru.godtools.account.GodToolsAccountManager
import org.cru.godtools.api.UserApi
import org.cru.godtools.api.UserFavoriteToolsApi
import org.cru.godtools.db.repository.ToolsRepository
import org.cru.godtools.db.repository.UserRepository
import org.cru.godtools.model.Tool
import org.cru.godtools.model.User
import org.cru.godtools.sync.repository.SyncRepository

@Singleton
internal class UserFavoriteToolsSyncTasks @Inject constructor(
private val accountManager: GodToolsAccountManager,
private val favoritesApi: UserFavoriteToolsApi,
private val syncRepository: SyncRepository,
private val toolsRepository: ToolsRepository,
private val userApi: UserApi,
private val userRepository: UserRepository,
) : BaseSyncTasks() {
private val favoritesUpdateMutex = Mutex()

suspend fun syncDirtyFavoriteTools(): Boolean = favoritesUpdateMutex.withLock {
coroutineScope {
if (!accountManager.isAuthenticated()) return@coroutineScope true
val userId = accountManager.userId().orEmpty()

val user = userRepository.findUser(userId)?.takeIf { it.isInitialFavoriteToolsSynced }
?: userApi.getUser().takeIf { it.isSuccessful }
?.body()?.dataSingle
?.also { syncRepository.storeUser(it) }
?: return@coroutineScope false

val favoritesToAdd = toolsRepository.getResources()
.filter {
(it.isFieldChanged(Tool.ATTR_IS_FAVORITE) || !user.isInitialFavoriteToolsSynced) && it.isFavorite
}

val params = JsonApiParams().fields(Tool.JSONAPI_TYPE, *Tool.JSONAPI_FIELDS)
if (favoritesToAdd.isNotEmpty()) {
favoritesApi.addFavoriteTools(params, favoritesToAdd).takeIf { it.isSuccessful }
?.body()?.data
?.also { syncRepository.storeFavoriteTools(it) }
?: return@coroutineScope false

if (!user.isInitialFavoriteToolsSynced) {
launch {
val update = JsonApiRetrofitObject.single(User(userId, isInitialFavoriteToolsSynced = true))
.apply {
options = JsonApiConverter.Options.Builder()
.fields(User.JSONAPI_TYPE, User.JSON_INITIAL_FAVORITE_TOOLS_SYNCED)
.build()
}

userApi.updateUser(update).takeIf { it.isSuccessful }
?.body()?.dataSingle
?.also { syncRepository.storeUser(it) }
?: return@launch
}
}
}

val favoritesToRemove = toolsRepository.getResources()
.filter { it.isFieldChanged(Tool.ATTR_IS_FAVORITE) && !it.isFavorite }

if (favoritesToRemove.isNotEmpty()) {
favoritesApi.removeFavoriteTools(params, favoritesToRemove).takeIf { it.isSuccessful }
?.body()?.data
?.also { syncRepository.storeFavoriteTools(it) }
?: return@coroutineScope false
}

true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package org.cru.godtools.sync.task

import io.mockk.Called
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coExcludeRecords
import io.mockk.coVerifySequence
import io.mockk.just
import io.mockk.mockk
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import org.ccci.gto.android.common.jsonapi.model.JsonApiObject
import org.cru.godtools.account.GodToolsAccountManager
import org.cru.godtools.api.UserApi
import org.cru.godtools.api.UserFavoriteToolsApi
import org.cru.godtools.db.repository.ToolsRepository
import org.cru.godtools.db.repository.UserRepository
import org.cru.godtools.model.Tool
import org.cru.godtools.model.User
import org.cru.godtools.model.trackChanges
import org.cru.godtools.sync.repository.SyncRepository
import retrofit2.Response

class UserFavoriteToolsSyncTasksTest {
private val userId = UUID.randomUUID().toString()

private val accountManager: GodToolsAccountManager = mockk {
coEvery { isAuthenticated() } returns true
coEvery { userId() } returns userId
}
private val favoritesApi: UserFavoriteToolsApi = mockk {
coEvery { addFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of())
coEvery { removeFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of())
}
private val syncRepository: SyncRepository = mockk {
coEvery { storeUser(any(), any()) } just Runs
coEvery { storeFavoriteTools(any(), any()) } just Runs
}
private val toolsRepository: ToolsRepository = mockk {
coEvery { getResources() } returns emptyList()
}
private val userApi: UserApi = mockk {
coEvery { getUser(any()) }
.returns(Response.success(JsonApiObject.single(User(userId, isInitialFavoriteToolsSynced = true))))
coEvery { updateUser(any()) } returns Response.success(JsonApiObject.single(User(userId)))
}
private val userRepository: UserRepository = mockk {
coEvery { findUser(userId) } returns User(userId, isInitialFavoriteToolsSynced = true)
}

private val tasks = UserFavoriteToolsSyncTasks(
accountManager = accountManager,
favoritesApi = favoritesApi,
syncRepository = syncRepository,
toolsRepository = toolsRepository,
userApi = userApi,
userRepository = userRepository,
)

// region syncDirtyFavoriteTools()
@Test
fun `syncDirtyFavoriteTools() - add new favorites`() = runTest {
val tools = listOf(
Tool("1") {
id = 1
isFavorite = true
},
Tool("2") {
id = 2
trackChanges { isFavorite = true }
},
Tool("3") {
id = 3
isFavorite = false
},
)
val responseTool = Tool("resp")

coEvery { toolsRepository.getResources() } returns tools
coEvery { favoritesApi.addFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of(responseTool))

assertTrue(tasks.syncDirtyFavoriteTools())
coVerifySequence {
favoritesApi.addFavoriteTools(any(), listOf(tools[1]))
syncRepository.storeFavoriteTools(listOf(responseTool), any())

userApi wasNot Called
}
}

@Test
fun `syncDirtyFavoriteTools() - initial favorites`() = runTest {
val user = User(userId, isInitialFavoriteToolsSynced = false)
val tools = listOf(
Tool("1") {
id = 1
isFavorite = true
},
Tool("2") {
id = 2
trackChanges { isFavorite = true }
},
Tool("3") {
id = 3
isFavorite = false
},
)
val responseTool = Tool("resp")

coEvery { userRepository.findUser(userId) } returns null
coEvery { userApi.getUser(any()) } returns Response.success(JsonApiObject.single(user))
coEvery { toolsRepository.getResources() } returns tools
coEvery { favoritesApi.addFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of(responseTool))
coExcludeRecords {
userApi.getUser(any())
syncRepository.storeUser(any(), any())
}

assertTrue(tasks.syncDirtyFavoriteTools())
coVerifySequence {
favoritesApi.addFavoriteTools(any(), listOf(tools[0], tools[1]))
syncRepository.storeFavoriteTools(listOf(responseTool), any())
userApi.updateUser(match { it.dataSingle == User(userId, isInitialFavoriteToolsSynced = true) })
}
}

@Test
fun `syncDirtyFavoriteTools() - remove old favorites`() = runTest {
val tools = listOf(
Tool("1") {
id = 1
isFavorite = true
},
Tool("2") {
id = 2
isFavorite = false
},
Tool("3") {
id = 3
isFavorite = true
trackChanges { isFavorite = false }
},
)
val responseTool = Tool("resp")

coEvery { toolsRepository.getResources() } returns tools
coEvery { favoritesApi.removeFavoriteTools(any(), any()) }
.returns(Response.success(JsonApiObject.of(responseTool)))

assertTrue(tasks.syncDirtyFavoriteTools())
coVerifySequence {
favoritesApi.removeFavoriteTools(any(), listOf(tools[2]))
syncRepository.storeFavoriteTools(listOf(responseTool), any())

userApi wasNot Called
}
}

@Test
fun `syncDirtyFavoriteTools() - not authenticated`() = runTest {
coEvery { accountManager.isAuthenticated() } returns false

assertTrue(tasks.syncDirtyFavoriteTools())
coVerifySequence {
accountManager.isAuthenticated()

userRepository wasNot Called
userApi wasNot Called
syncRepository wasNot Called
toolsRepository wasNot Called
favoritesApi wasNot Called
}
}

@Test
fun `syncDirtyFavoriteTools() - user not found`() = runTest {
coEvery { userRepository.findUser(userId) } returns null
coEvery { userApi.getUser(any()) } returns Response.success(JsonApiObject.of())

assertFalse(tasks.syncDirtyFavoriteTools())
coVerifySequence {
userRepository.findUser(userId)
userApi.getUser(any())

syncRepository wasNot Called
toolsRepository wasNot Called
favoritesApi wasNot Called
}
}

@Test
fun `syncDirtyFavoriteTools() - user resolved from API - user not found`() = runTest {
coEvery { userRepository.findUser(userId) } returns null

assertTrue(tasks.syncDirtyFavoriteTools())
coVerifySequence {
userRepository.findUser(userId)
userApi.getUser(any())
syncRepository.storeUser(any(), any())
}
}

@Test
fun `syncDirtyFavoriteTools() - user resolved from API - user hasn't synced initial favorites`() = runTest {
coEvery { userRepository.findUser(userId) } returns User(userId, isInitialFavoriteToolsSynced = false)

assertTrue(tasks.syncDirtyFavoriteTools())
coVerifySequence {
userRepository.findUser(userId)
userApi.getUser(any())
syncRepository.storeUser(any(), any())
}
}
// endregion syncDirtyFavoriteTools()
}

0 comments on commit 0ed9873

Please sign in to comment.