From 0ed98736fa916006cfce953da1d445c07566e474 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 29 Sep 2023 11:05:56 -0600 Subject: [PATCH] create a dirty favorite tools sync task --- .../kotlin/org/cru/godtools/api/UserApi.kt | 6 + .../kotlin/org/cru/godtools/model/User.kt | 8 +- .../sync/repository/SyncRepository.kt | 4 +- .../sync/task/UserFavoriteToolsSyncTasks.kt | 85 +++++++ .../task/UserFavoriteToolsSyncTasksTest.kt | 218 ++++++++++++++++++ 5 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt create mode 100644 library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt 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 8857e9f65e..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,9 +2,12 @@ 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" @@ -12,4 +15,7 @@ internal const val PATH_USER = "users/me" interface UserApi { @GET(PATH_USER) suspend fun getUser(@QueryMap params: JsonApiParams = JsonApiParams()): Response> + + @PATCH(PATH_USER) + suspend fun updateUser(@Body user: JsonApiRetrofitObject): Response> } diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt index 0fa2d6716f..f0de30e5e2 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt @@ -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 = "", @@ -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" } 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..1fbe50c0c0 --- /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 = 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 + } + } +} 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..81b58f7c45 --- /dev/null +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt @@ -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() +}