diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt b/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt index 643081f84b..7c8209cc5c 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt @@ -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): Response> + suspend fun getUser(@QueryMap params: JsonApiParams = JsonApiParams()): Response> + + @PATCH(PATH_USER) + suspend fun updateUser(@Body user: JsonApiRetrofitObject): Response> } diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt index 863bc06b8e..ee05c501a4 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt @@ -92,7 +92,7 @@ internal class SyncRepository @Inject constructor( return setOfNotNull(tool.code) + processIncludes(tool, includes) } - private suspend fun storeFavoriteTools(tools: List, includes: Includes) { + suspend fun storeFavoriteTools(tools: List, includes: Includes = Includes()) { storeTools(tools, includes = includes) toolsRepository.storeFavoriteToolsFromSync(tools) } @@ -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)) { diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt new file mode 100644 index 0000000000..6606f4ddc3 --- /dev/null +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt @@ -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 = coroutineScope { + favoritesUpdateMutex.withLock { + 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 + } + } +} diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt new file mode 100644 index 0000000000..d01b2f6eac --- /dev/null +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt @@ -0,0 +1,112 @@ +package org.cru.godtools.sync.task + +import io.mockk.Called +import io.mockk.Runs +import io.mockk.coEvery +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.User +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() + private val syncRepository: SyncRepository = mockk { + coEvery { storeUser(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)))) + } + 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() - 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() +}