diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e42cc7b2..73c695c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ koin = "4.0.4" coil = "3.2.0" about-libraries = "12.2.0" mockito = "5.18.0" +mockito-kotlin = "5.4.0" kover = "0.9.1" androidx-test-runner = "1.6.2" @@ -95,6 +96,7 @@ androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", versi androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } androidx-ui-tooling-preview-android = { module = "androidx.compose.ui:ui-tooling-preview-android", version.ref = "compose" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } @@ -115,6 +117,7 @@ coil = ["coil-compose", "coil-network"] mockito = [ "mockito-core", + "mockito-kotlin", ] [plugins] diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncIntegrationTest.kt similarity index 85% rename from tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt rename to tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncIntegrationTest.kt index e902f63e..27269043 100644 --- a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncIntegrationTest.kt @@ -33,9 +33,9 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull -class TaskRepositorySyncTest { +class TaskRepositorySyncIntegrationTest { @Test - fun `sync remote task lists`() { + fun `when remote task lists with tasks then sync should store data locally`() { val taskListsApi = InMemoryTaskListsApi("My tasks", "Other tasks") val tasksApi = InMemoryTasksApi("1" to listOf("First task TODO"), "2" to listOf("Another task")) @@ -43,11 +43,9 @@ class TaskRepositorySyncTest { val initialTaskLists = repository.getTaskLists().firstOrNull() assertEquals(0, initialTaskLists?.size, "There shouldn't be any task list at start") - repository.sync() + repository.sync(cleanStaleTasks = false) - assertEquals(1, taskListsApi.requestCount) assertContentEquals(listOf("list"), taskListsApi.requests) - assertEquals(2, tasksApi.requestCount) assertContentEquals(listOf("list", "list"), tasksApi.requests) val taskLists = repository.getTaskLists().firstOrNull() @@ -66,7 +64,7 @@ class TaskRepositorySyncTest { } @Test - fun `task list CRUD without network should create a local only task list`() { + fun `when network is OFF then created task list should be local only`() { val taskListsApi = InMemoryTaskListsApi() runTaskRepositoryTest(taskListsApi) { repository -> @@ -80,10 +78,11 @@ class TaskRepositorySyncTest { } @Test - fun `local only task lists are synced at next sync`() { - val taskListsApi = InMemoryTaskListsApi() + fun `when there are local only task lists then sync should upload them`() { + val taskListsApi = InMemoryTaskListsApi("Task list") + val tasksApi = InMemoryTasksApi() - runTaskRepositoryTest(taskListsApi) { repository -> + runTaskRepositoryTest(taskListsApi, tasksApi) { repository -> // for first request, no network taskListsApi.isNetworkAvailable = false repository.createTaskList("Task list") @@ -93,9 +92,9 @@ class TaskRepositorySyncTest { // network is considered back, sync should trigger fetch & push requests taskListsApi.isNetworkAvailable = true - repository.sync() - assertEquals(2, taskListsApi.requestCount) + repository.sync(cleanStaleTasks = false) assertContentEquals(listOf("list", "insert"), taskListsApi.requests) + assertContentEquals(listOf("list"), tasksApi.requests) } } } \ No newline at end of file diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/InMemoryTasksApi.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/InMemoryTasksApi.kt index a1b007c4..5c35d17f 100644 --- a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/InMemoryTasksApi.kt +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/InMemoryTasksApi.kt @@ -154,7 +154,7 @@ class InMemoryTasksApi( return handleRequest("list") { // TODO maxResults & token handling val tasks = synchronized(this) { - storage[taskListId] ?: error("Task list ($taskListId) not found") + storage[taskListId] ?: emptyList() } val filteredTasks = tasks.filter { task -> // Check if completed tasks should be shown diff --git a/tasks-core/build.gradle.kts b/tasks-core/build.gradle.kts index febf3a64..d9bb8007 100644 --- a/tasks-core/build.gradle.kts +++ b/tasks-core/build.gradle.kts @@ -45,6 +45,10 @@ kotlin { commonTest.dependencies { implementation(kotlin("test")) + + implementation(libs.kotlinx.coroutines.test) + + implementation(libs.bundles.mockito) } } } \ No newline at end of file diff --git a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt index 321f8a3e..d2671499 100644 --- a/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt +++ b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt @@ -22,6 +22,7 @@ package net.opatry.tasks.data +import androidx.annotation.VisibleForTesting import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -34,14 +35,14 @@ import kotlinx.datetime.Instant import net.opatry.google.tasks.TaskListsApi import net.opatry.google.tasks.TasksApi import net.opatry.google.tasks.listAll -import net.opatry.google.tasks.model.Task -import net.opatry.google.tasks.model.TaskList import net.opatry.tasks.NowProvider -import net.opatry.tasks.data.entity.TaskEntity -import net.opatry.tasks.data.entity.TaskListEntity import net.opatry.tasks.data.model.TaskDataModel import net.opatry.tasks.data.model.TaskListDataModel import java.math.BigInteger +import net.opatry.google.tasks.model.Task as RemoteTask +import net.opatry.google.tasks.model.TaskList as RemoteTaskList +import net.opatry.tasks.data.entity.TaskEntity as LocalTask +import net.opatry.tasks.data.entity.TaskListEntity as LocalTaskList enum class TaskListSorting { Manual, @@ -49,8 +50,8 @@ enum class TaskListSorting { Title, } -private fun TaskList.asTaskListEntity(localId: Long?, sorting: TaskListEntity.Sorting): TaskListEntity { - return TaskListEntity( +private fun RemoteTaskList.asTaskListEntity(localId: Long?, sorting: LocalTaskList.Sorting): LocalTaskList { + return LocalTaskList( id = localId ?: 0, remoteId = id, etag = etag, @@ -60,13 +61,9 @@ private fun TaskList.asTaskListEntity(localId: Long?, sorting: TaskListEntity.So ) } -// TODO invert taskId & parentTaskId parameters -// Do it so that: -// no risk of tedious conflict -// replace call site with name= -// ensure call site order is properly switch accordingly (/!\ IDEA "flip ','" doesn't do it for us) -private fun Task.asTaskEntity(parentListLocalId: Long, taskLocalId: Long?, parentTaskLocalId: Long?): TaskEntity { - return TaskEntity( +@VisibleForTesting +internal fun RemoteTask.asTaskEntity(parentListLocalId: Long, parentTaskLocalId: Long?, taskLocalId: Long?): LocalTask { + return LocalTask( id = taskLocalId ?: 0, remoteId = id, parentListLocalId = parentListLocalId, @@ -83,18 +80,18 @@ private fun Task.asTaskEntity(parentListLocalId: Long, taskLocalId: Long?, paren ) } -private fun TaskListEntity.asTaskListDataModel(tasks: List): TaskListDataModel { - val parentIds = tasks.map(TaskEntity::parentTaskLocalId).toSet() +private fun LocalTaskList.asTaskListDataModel(tasks: List): TaskListDataModel { + val parentIds = tasks.map(LocalTask::parentTaskLocalId).toSet() val (sorting, sortedTasks) = when (sorting) { - TaskListEntity.Sorting.UserDefined -> TaskListSorting.Manual to sortTasksManualOrdering(tasks).map { (task, indent) -> + LocalTaskList.Sorting.UserDefined -> TaskListSorting.Manual to sortTasksManualOrdering(tasks).map { (task, indent) -> task.asTaskDataModel(indent, !task.isCompleted && task.id in parentIds) } - TaskListEntity.Sorting.DueDate -> TaskListSorting.DueDate to sortTasksDateOrdering(tasks).map { task -> + LocalTaskList.Sorting.DueDate -> TaskListSorting.DueDate to sortTasksDateOrdering(tasks).map { task -> task.asTaskDataModel(0, !task.isCompleted && task.id in parentIds) } - TaskListEntity.Sorting.Title -> TaskListSorting.Title to sortTasksTitleOrdering(tasks).map { task -> + LocalTaskList.Sorting.Title -> TaskListSorting.Title to sortTasksTitleOrdering(tasks).map { task -> task.asTaskDataModel(0, !task.isCompleted && task.id in parentIds) } } @@ -115,7 +112,7 @@ private fun TaskListEntity.asTaskListDataModel(tasks: List): TaskLis ) } -private fun TaskEntity.asTaskDataModel(indent: Int, isParentTask: Boolean): TaskDataModel { +private fun LocalTask.asTaskDataModel(indent: Int, isParentTask: Boolean): TaskDataModel { return TaskDataModel( id = id, title = title, @@ -130,14 +127,15 @@ private fun TaskEntity.asTaskDataModel(indent: Int, isParentTask: Boolean): Task ) } -private fun TaskEntity.asTask(): Task { - return Task( +@VisibleForTesting +internal fun LocalTask.asTask(): RemoteTask { + return RemoteTask( id = remoteId ?: "", title = title, notes = notes, dueDate = dueDate, updatedDate = lastUpdateDate, - status = if (isCompleted) Task.Status.Completed else Task.Status.NeedsAction, + status = if (isCompleted) RemoteTask.Status.Completed else RemoteTask.Status.NeedsAction, completedDate = completionDate, // doc says it's a read only field, but status is not hidden when syncing local only completed tasks // forcing the hidden status works and makes everything more consistent (position following 099999... pattern, hidden status) @@ -146,12 +144,12 @@ private fun TaskEntity.asTask(): Task { ) } -fun sortTasksManualOrdering(tasks: List): List> { +fun sortTasksManualOrdering(tasks: List): List> { // Step 1: Create a map of tasks by their IDs for easy lookup - val taskMap = tasks.associateBy(TaskEntity::id).toMutableMap() + val taskMap = tasks.associateBy(LocalTask::id).toMutableMap() // Step 2: Build a tree structure with parent-child relationships - val tree = mutableMapOf>() + val tree = mutableMapOf>() tasks.forEach { task -> val parentId = task.parentTaskLocalId if (parentId == null) { @@ -165,11 +163,11 @@ fun sortTasksManualOrdering(tasks: List): List // Step 3: Sort the child tasks by position tree.forEach { (_, children) -> - children.sortBy(TaskEntity::position) + children.sortBy(LocalTask::position) } // Step 4: Recursive function to traverse tasks and assign indentation levels - fun traverseTasks(taskId: Long, level: Int, result: MutableList>) { + fun traverseTasks(taskId: Long, level: Int, result: MutableList>) { val task = taskMap[taskId] ?: return result.add(task to level) val children = tree[taskId] ?: return @@ -179,7 +177,7 @@ fun sortTasksManualOrdering(tasks: List): List } // Step 5: Start traversal from the root tasks (tasks with no parents) - val sortedTasks = mutableListOf>() + val sortedTasks = mutableListOf>() tree.keys.filter { taskMap[it]?.parentTaskRemoteId == null }.sortedBy { taskMap[it]?.position }.forEach { traverseTasks(it, 0, sortedTasks) } @@ -187,27 +185,27 @@ fun sortTasksManualOrdering(tasks: List): List return sortedTasks } -fun sortTasksDateOrdering(tasks: List): List { - val (completedTasks, remainingTasks) = tasks.partition(TaskEntity::isCompleted) +fun sortTasksDateOrdering(tasks: List): List { + val (completedTasks, remainingTasks) = tasks.partition(LocalTask::isCompleted) val sortedRemainingTasks = remainingTasks.sortedWith( - compareBy { it.dueDate == null } - .thenBy(TaskEntity::dueDate) + compareBy { it.dueDate == null } + .thenBy(LocalTask::dueDate) ) return sortedRemainingTasks + sortCompletedTasks(completedTasks) } -fun sortTasksTitleOrdering(tasks: List): List { - val (completedTasks, remainingTasks) = tasks.partition(TaskEntity::isCompleted) +fun sortTasksTitleOrdering(tasks: List): List { + val (completedTasks, remainingTasks) = tasks.partition(LocalTask::isCompleted) val sortedRemainingTasks = remainingTasks.sortedWith( - compareBy { it.title.lowercase() } - .thenByDescending(TaskEntity::title) + compareBy { it.title.lowercase() } + .thenByDescending(LocalTask::title) ) return sortedRemainingTasks + sortCompletedTasks(completedTasks) } -fun sortCompletedTasks(tasks: List): List { - require(tasks.all(TaskEntity::isCompleted)) { "Only completed tasks can be sorted" } - return tasks.sortedBy(TaskEntity::position) +fun sortCompletedTasks(tasks: List): List { + require(tasks.all(LocalTask::isCompleted)) { "Only completed tasks can be sorted" } + return tasks.sortedBy(LocalTask::position) } /** @@ -216,18 +214,18 @@ fun sortCompletedTasks(tasks: List): List { * The completed tasks are sorted last and sorted by completion date. * Position is reset for each list & parent task. */ -fun computeTaskPositions(tasks: List, newPositionStart: Int = 0): List { +fun computeTaskPositions(tasks: List, newPositionStart: Int = 0): List { return buildList { - val tasksByList = tasks.groupBy(TaskEntity::parentListLocalId) + val tasksByList = tasks.groupBy(LocalTask::parentListLocalId) tasksByList.forEach { (_, tasks) -> - tasks.groupBy(TaskEntity::parentTaskLocalId).forEach { (_, subTasks) -> + tasks.groupBy(LocalTask::parentTaskLocalId).forEach { (_, subTasks) -> val (completed, todo) = subTasks.partition { it.isCompleted && it.completionDate != null } val completedWithPositions = completed.map { it.copy(position = computeCompletedTaskPosition(it)) } - val todoWithPositions = todo.mapIndexed { index, taskEntity -> - taskEntity.copy(position = (newPositionStart + index).toTaskPosition()) + val todoWithPositions = todo.mapIndexed { index, LocalTask -> + LocalTask.copy(position = (newPositionStart + index).toTaskPosition()) } - val sortedSubTasks = (todoWithPositions + completedWithPositions).sortedBy(TaskEntity::position) + val sortedSubTasks = (todoWithPositions + completedWithPositions).sortedBy(LocalTask::position) addAll(sortedSubTasks) } } @@ -236,7 +234,7 @@ fun computeTaskPositions(tasks: List, newPositionStart: Int = 0): Li fun Number.toTaskPosition(): String = this.toString().padStart(20, '0') -fun computeCompletedTaskPosition(task: TaskEntity): String { +fun computeCompletedTaskPosition(task: LocalTask): String { val completionDate = task.completionDate require(task.isCompleted && completionDate != null) { "Task must be completed and have a completion date" @@ -263,6 +261,9 @@ class TaskRepository( private val tasksApi: TasksApi, private val nowProvider: NowProvider, ) { + // TODO persist it + private var lastSync: Instant? = null + @OptIn(ExperimentalCoroutinesApi::class) fun getTaskLists() = taskListDao.getAllTaskListsWithTasksAsFlow() .distinctUntilChanged() @@ -272,86 +273,193 @@ class TaskRepository( } } - suspend fun sync() { - val taskListIds = mutableMapOf() + suspend fun cleanStaleTasks(remoteTaskLists: List? = null) { + val remoteTaskListIds = when (remoteTaskLists) { + null -> withContext(Dispatchers.IO) { + try { + taskListsApi.listAll() + } catch (_: Exception) { + null + } + } + + else -> remoteTaskLists + }?.map(RemoteTaskList::id) ?: return + + remoteTaskListIds.forEach { remoteTaskListId -> + val localTaskListId = taskListDao.getByRemoteId(remoteTaskListId)?.id ?: return@forEach + val remoteTaskIds = withContext(Dispatchers.IO) { + tasksApi.listAll( + taskListId = remoteTaskListId, + showDeleted = true, + showHidden = true, + showCompleted = true, + updatedMin = null, // we need all content to purge local data accurately + ) + }.map(RemoteTask::id) + + taskDao.deleteStaleTasks(localTaskListId, remoteTaskIds) + } + + taskListDao.deleteStaleTaskLists(remoteTaskListIds) + } + + suspend fun sync(cleanStaleTasks: Boolean = lastSync == null) { val remoteTaskLists = withContext(Dispatchers.IO) { try { taskListsApi.listAll() } catch (_: Exception) { null } + } ?: return // most likely not internet, can't fetch data, nothing to sync + + // update local lists from remote counterparts + val syncedTaskLists = remoteTaskLists.map { remoteTaskList -> + val existingLocalList = taskListDao.getByRemoteId(remoteTaskList.id) + remoteTaskList.asTaskListEntity( + localId = existingLocalList?.id, + sorting = existingLocalList?.sorting ?: LocalTaskList.Sorting.UserDefined + ) + }.takeUnless(List<*>::isEmpty)?.let { lists -> + val finalListIds = taskListDao.upsertAll(lists) + // update list ids following upsertAll + lists.zip(finalListIds) { list, finalId -> list.copy(id = finalId) } + } ?: emptyList() + + // pull remote tasks + syncedTaskLists.flatMap { taskList -> + taskList.remoteId?.let { remoteTaskListId -> + pullRemoteTasks(taskList.id, remoteTaskListId) + } ?: emptyList() + }.also { syncedTasks -> + if (syncedTasks.isNotEmpty()) { + taskDao.upsertAll(syncedTasks) + } } - if (remoteTaskLists == null) { - // most likely not internet, can't fetch data, nothing to sync - return + // push local only lists + val localOnlySyncedTaskLists = taskListDao.getLocalOnlyTaskLists().mapNotNull { localTaskList -> + pushLocalTaskList(localTaskList) + }.takeUnless(List<*>::isEmpty)?.let { syncedTaskLists -> + val finalListIds = taskListDao.upsertAll(syncedTaskLists) + // update list ids following upsertAll + syncedTaskLists.zip(finalListIds) { list, finalId -> list.copy(id = finalId) } + } ?: emptyList() + + // push local only tasks + val allTaskLists = syncedTaskLists + localOnlySyncedTaskLists + allTaskLists.flatMap { taskList -> + val localOnlyTasks = taskDao.getLocalOnlyTasks(taskList.id) + taskList.remoteId?.let { remoteTaskListId -> + pushLocalTasks( + localTaskListId = taskList.id, + remoteTaskListId = remoteTaskListId, + localOnlyTasks, + ) + } ?: emptyList() + }.also { syncedTasks -> + if (syncedTasks.isNotEmpty()) { + taskDao.upsertAll(syncedTasks) + } } - remoteTaskLists.onEach { remoteTaskList -> - // FIXME suboptimal - // - check stale ones in DB and remove them if not only local - // - check no update, and ignore/filter - // - check new ones - // - etc. - val existingEntity = taskListDao.getByRemoteId(remoteTaskList.id) - val updatedEntity = remoteTaskList.asTaskListEntity(existingEntity?.id, existingEntity?.sorting ?: TaskListEntity.Sorting.UserDefined) - val finalLocalId = taskListDao.upsert(updatedEntity) - taskListIds[finalLocalId] = remoteTaskList.id + lastSync = nowProvider.now() + + if (cleanStaleTasks) { + cleanStaleTasks(remoteTaskLists) } - taskListDao.deleteStaleTaskLists(remoteTaskLists.map(TaskList::id)) - taskListDao.getLocalOnlyTaskLists().onEach { localTaskList -> - val remoteTaskList = withContext(Dispatchers.IO) { - try { - taskListsApi.insert(TaskList(localTaskList.title)) - } catch (_: Exception) { - null - } - } - if (remoteTaskList != null) { - taskListDao.upsert(remoteTaskList.asTaskListEntity(localTaskList.id, localTaskList.sorting)) + } + + private suspend fun pushLocalTaskList(localTaskList: LocalTaskList): LocalTaskList? { + return withContext(Dispatchers.IO) { + try { + taskListsApi.insert( + RemoteTaskList( + title = localTaskList.title, + updatedDate = localTaskList.lastUpdateDate + ) + ) + } catch (_: Exception) { + null } + }?.asTaskListEntity(localTaskList.id, localTaskList.sorting) + } + + private suspend fun pullRemoteTasks(localTaskListId: Long, remoteTaskListId: String): List { + return withContext(Dispatchers.IO) { + tasksApi.listAll( + taskListId = remoteTaskListId, + showDeleted = false, + showHidden = true, + showCompleted = true, + updatedMin = lastSync, + ) + }.map { remoteTask -> + val existingLocalTask = taskDao.getByRemoteId(remoteTask.id) + val localParentTask = remoteTask.parent?.let { taskDao.getByRemoteId(it) } + remoteTask.asTaskEntity( + parentListLocalId = localTaskListId, + parentTaskLocalId = localParentTask?.id, + taskLocalId = existingLocalTask?.id, + ) } - taskListIds.forEach { (localListId, remoteListId) -> - // TODO deal with showDeleted, showHidden, etc. - // TODO updatedMin could be used to filter out unchanged tasks since last sync - // /!\ this would impact the deleteStaleTasks logic - val remoteTasks = withContext(Dispatchers.IO) { - tasksApi.listAll(remoteListId, showHidden = true, showCompleted = true) - } - remoteTasks.onEach { remoteTask -> - val existingEntity = taskDao.getByRemoteId(remoteTask.id) - val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - taskDao.upsert(remoteTask.asTaskEntity(localListId, existingEntity?.id, parentTaskEntity?.id)) - } - taskDao.deleteStaleTasks(localListId, remoteTasks.map(Task::id)) - taskDao.getLocalOnlyTasks(localListId).onEach { localTask -> + } + + private suspend fun pushLocalTasks( + localTaskListId: Long, + remoteTaskListId: String, + tasks: List + ): List { + // iterate on tasks grouped by parent, top-level tasks being processed first + val allTasksToSync = tasks.groupBy(LocalTask::parentTaskLocalId) + .toSortedMap(compareBy { it != null }) // null comes first + .toMutableMap() + val syncedTasks = mutableMapOf() + val syncFailedTaskIds = mutableListOf() + allTasksToSync.onEach { (localParentTaskId, tasksToSync) -> + // don't try syncing sub tasks if parent task failed, it would break hierarchy on remote side + if (localParentTaskId in syncFailedTaskIds) return@onEach + + var previousTaskId: String? = null + tasksToSync.onEach { localTask -> val remoteTask = withContext(Dispatchers.IO) { try { - tasksApi.insert(remoteListId, localTask.asTask()) + tasksApi.insert( + taskListId = remoteTaskListId, + task = localTask.asTask(), + parentTaskId = syncedTasks[localParentTaskId]?.remoteId, + previousTaskId = previousTaskId, + ).also { remoteTask -> + syncedTasks[localTask.id] = remoteTask.asTaskEntity( + parentListLocalId = localTaskListId, + parentTaskLocalId = localParentTaskId, + taskLocalId = localTask.id, + ) + } } catch (_: Exception) { + syncFailedTaskIds += localTask.id null } } - if (remoteTask != null) { - val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - taskDao.upsert(remoteTask.asTaskEntity(localListId, localTask.id, parentTaskEntity?.id)) - } + // FIXME if one of the task sync fails, it breaks sibling order + previousTaskId = remoteTask?.id } } + return syncedTasks.values.toList() } suspend fun createTaskList(title: String): Long { val now = nowProvider.now() - val taskListId = taskListDao.insert(TaskListEntity(title = title, lastUpdateDate = now)) + val taskListId = taskListDao.insert(LocalTaskList(title = title, lastUpdateDate = now)) val taskList = withContext(Dispatchers.IO) { try { - taskListsApi.insert(TaskList(title = title, updatedDate = now)) + taskListsApi.insert(RemoteTaskList(title = title, updatedDate = now)) } catch (_: Exception) { null } } if (taskList != null) { - taskListDao.upsert(taskList.asTaskListEntity(taskListId, TaskListEntity.Sorting.UserDefined)) + taskListDao.upsert(taskList.asTaskListEntity(taskListId, LocalTaskList.Sorting.UserDefined)) } return taskListId } @@ -384,7 +492,7 @@ class TaskRepository( try { taskListsApi.update( taskListEntity.remoteId, - TaskList( + RemoteTaskList( id = taskListEntity.remoteId, title = taskListEntity.title, updatedDate = taskListEntity.lastUpdateDate @@ -404,7 +512,7 @@ class TaskRepository( val taskList = requireNotNull(taskListDao.getById(taskListId)) { "Invalid task list id $taskListId" } // TODO local update date task list val completedTasks = taskDao.getCompletedTasks(taskListId) - taskDao.deleteTasks(completedTasks.map(TaskEntity::id)) + taskDao.deleteTasks(completedTasks.map(LocalTask::id)) if (taskList.remoteId != null) { coroutineScope { completedTasks.mapNotNull { task -> @@ -424,9 +532,9 @@ class TaskRepository( suspend fun sortTasksBy(taskListId: Long, sorting: TaskListSorting) { val dbSorting = when (sorting) { - TaskListSorting.Manual -> TaskListEntity.Sorting.UserDefined - TaskListSorting.DueDate -> TaskListEntity.Sorting.DueDate - TaskListSorting.Title -> TaskListEntity.Sorting.Title + TaskListSorting.Manual -> LocalTaskList.Sorting.UserDefined + TaskListSorting.DueDate -> LocalTaskList.Sorting.DueDate + TaskListSorting.Title -> LocalTaskList.Sorting.Title } // no update date change, it's a local only information unrelated to remote tasks taskListDao.sortTasksBy(taskListId, dbSorting) @@ -439,7 +547,7 @@ class TaskRepository( val firstPosition = 0.toTaskPosition() val currentTasks = taskDao.getTasksFromPositionOnward(taskListId, parentTaskId, firstPosition) .toMutableList() - val taskEntity = TaskEntity( + val taskEntity = LocalTask( parentListLocalId = taskListId, parentTaskLocalId = parentTaskId, title = title, @@ -465,7 +573,13 @@ class TaskRepository( } } if (task != null) { - taskDao.upsert(task.asTaskEntity(taskListId, taskId, parentTaskId)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = taskListId, + parentTaskLocalId = parentTaskId, + taskLocalId = taskId, + ) + ) } } return taskId @@ -498,7 +612,7 @@ class TaskRepository( // TODO pending deletion } - private suspend fun applyTaskUpdate(taskId: Long, updateLogic: suspend (TaskEntity, Instant) -> TaskEntity?) { + private suspend fun applyTaskUpdate(taskId: Long, updateLogic: suspend (LocalTask, Instant) -> LocalTask?) { val now = nowProvider.now() val taskEntity = requireNotNull(taskDao.getById(taskId)) { "Invalid task id $taskId" } val updatedTaskEntity = updateLogic(taskEntity, now) ?: return @@ -520,7 +634,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(updatedTaskEntity.parentListLocalId, taskId, updatedTaskEntity.parentTaskLocalId)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = updatedTaskEntity.parentListLocalId, + parentTaskLocalId = updatedTaskEntity.parentTaskLocalId, + taskLocalId = taskId, + ) + ) } } } @@ -528,9 +648,10 @@ class TaskRepository( suspend fun toggleTaskCompletionState(taskId: Long) { applyTaskUpdate(taskId) { taskEntity, updateTime -> // TODO should update position when changed/restored to not completed, what should it be? + val isNowCompleted = !taskEntity.isCompleted taskEntity.copy( - isCompleted = !taskEntity.isCompleted, - completionDate = if (taskEntity.isCompleted) null else updateTime, + isCompleted = isNowCompleted, + completionDate = if (isNowCompleted) updateTime else null, lastUpdateDate = updateTime, ) } @@ -625,7 +746,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(updatedTaskEntity.parentListLocalId, updatedTaskEntity.id, parentTaskEntity.id)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = updatedTaskEntity.parentListLocalId, + parentTaskLocalId = parentTaskEntity.id, + taskLocalId = updatedTaskEntity.id, + ) + ) } } } @@ -677,7 +804,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(updatedTaskEntity.parentListLocalId, updatedTaskEntity.id, null)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = updatedTaskEntity.parentListLocalId, + parentTaskLocalId = null, + taskLocalId = updatedTaskEntity.id, + ) + ) } } } @@ -715,7 +848,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(updatedTaskEntity.parentListLocalId, taskId, null)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = updatedTaskEntity.parentListLocalId, + parentTaskLocalId = null, + taskLocalId = taskId, + ) + ) } } } @@ -768,7 +907,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(destinationListId, taskId, null)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = destinationListId, + parentTaskLocalId = null, + taskLocalId = taskId, + ) + ) } } } diff --git a/tasks-core/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt b/tasks-core/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt new file mode 100644 index 00000000..42d4107d --- /dev/null +++ b/tasks-core/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt @@ -0,0 +1,1005 @@ +/* + * Copyright (c) 2025 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data + + +import io.ktor.client.plugins.ServerResponseException +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import net.opatry.google.tasks.TaskListsApi +import net.opatry.google.tasks.TasksApi +import net.opatry.google.tasks.model.ResourceListResponse +import net.opatry.tasks.NowProvider +import org.junit.runner.RunWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.then +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds +import net.opatry.google.tasks.model.Task as RemoteTask +import net.opatry.google.tasks.model.TaskList as RemoteTaskList +import net.opatry.tasks.data.entity.TaskEntity as LocalTask +import net.opatry.tasks.data.entity.TaskListEntity as LocalTaskList + +@RunWith(MockitoJUnitRunner::class) +class TaskRepositorySyncTest { + //region Mocks + + @Mock + private lateinit var taskListDao: TaskListDao + + @Mock + private lateinit var taskDao: TaskDao + + @Mock + private lateinit var taskListsApi: TaskListsApi + + @Mock + private lateinit var tasksApi: TasksApi + + @Mock + private lateinit var nowProvider: NowProvider + + @InjectMocks + private lateinit var repository: TaskRepository + + private fun mockRemoteTaskListResponse( + remoteId: String, + title: String, + updatedDate: Instant, + ): ResourceListResponse = + mock { + on { items } doReturn listOf(RemoteTaskList(id = remoteId, title = title, updatedDate = updatedDate)) + on { nextPageToken } doReturn null + } + + private fun mockNoRemoteTaskListsResponse(): ResourceListResponse = + mock { + on { items } doReturn emptyList() + on { nextPageToken } doReturn null + } + + private suspend fun stubRemoteTaskListsFailure() { + whenever(taskListsApi.list(maxResults = 100, null)) + .thenThrow(ServerResponseException::class.java) + } + + private suspend fun stubNoRemoteTaskLists() { + val listsResponse = mockNoRemoteTaskListsResponse() + whenever(taskListsApi.list(maxResults = 100, null)) + .thenReturn(listsResponse) + } + + private suspend fun stubRemoteTaskList( + remoteId: String = "remoteId", + title: String = "list", + updatedDate: Instant = Clock.System.now(), + ) { + val listsResponse = mockRemoteTaskListResponse(remoteId = remoteId, title = title, updatedDate = updatedDate) + whenever(taskListsApi.list(maxResults = 100, null)) + .thenReturn(listsResponse) + } + + private suspend fun stubRemoteTaskListSynced( + remoteId: String = "remoteId", + title: String = "list", + updatedDate: Instant = Clock.System.now(), + ): Long { + stubRemoteTaskList(remoteId = remoteId, title = title, updatedDate = updatedDate) + val localTaskList = LocalTaskList(id = 42, remoteId = remoteId, title = title, lastUpdateDate = updatedDate) + whenever(taskListDao.getByRemoteId(remoteId)).thenReturn(localTaskList) + whenever(taskListDao.upsertAll(listOf(localTaskList))) + .thenReturn(listOf(localTaskList.id)) + return localTaskList.id + } + + private fun mockRemoteTasksResponse(vararg remoteTasks: RemoteTask): ResourceListResponse = + mock { + on { items } doReturn remoteTasks.toList() + on { nextPageToken } doReturn null + } + + private suspend fun stubRemoteTasks( + taskListId: String, + updatedMin: Instant?, + vararg remoteTasks: RemoteTask, + ) { + val response = mockRemoteTasksResponse(*remoteTasks) + whenever( + tasksApi.list( + taskListId = taskListId, + showDeleted = false, + showHidden = true, + showCompleted = true, + maxResults = 100, + updatedMin = updatedMin, + completedMin = null, + completedMax = null, + dueMin = null, + dueMax = null, + ) + ).thenReturn(response) + } + + private suspend fun stubNoRemoteTasks( + taskListId: String, + updatedMin: Instant? = null, + ) { + val response = mockRemoteTasksResponse(*emptyArray()) + whenever( + tasksApi.list( + taskListId = taskListId, + showDeleted = false, + showHidden = true, + showCompleted = true, + maxResults = 100, + updatedMin = updatedMin, + completedMin = null, + completedMax = null, + dueMin = null, + dueMax = null, + ) + ).thenReturn(response) + } + + private suspend fun stubNoLocalOnlyTaskLists() { + whenever(taskListDao.getLocalOnlyTaskLists()).thenReturn(emptyList()) + } + + private suspend fun stubNoLocalOnlyTasks(taskListId: Long) { + whenever(taskDao.getLocalOnlyTasks(taskListId)).thenReturn(emptyList()) + } + + //endregion + + //region TaskList pull + + @Test + fun `when remote listing fails then sync should do nothing`() = runTest { + // Given + stubRemoteTaskListsFailure() + + // When + repository.sync(false) + + // Then + verify(taskListsApi).list(maxResults = 100, null) + verifyNoMoreInteractions(taskListsApi) + verifyNoInteractions(taskListDao, taskDao, tasksApi, nowProvider) + } + + @Test + fun `when no remote lists then sync should do almost nothing`() = runTest { + // Given + stubNoRemoteTaskLists() + stubNoLocalOnlyTaskLists() + stubNoLocalOnlyTasks(42) + whenever(nowProvider.now()).thenReturn(mock()) + + // When + repository.sync(false) + + // Then + verify(taskListsApi).list(maxResults = 100, null) + verify(taskListDao).getLocalOnlyTaskLists() + verify(nowProvider).now() + verifyNoMoreInteractions(taskListsApi, taskListDao, nowProvider) + verifyNoInteractions(taskDao, tasksApi) + } + + @Test + fun `when delete stale tasks is requested then sync should call clean stale tasks`() = runTest { + // Given + stubNoRemoteTaskLists() + stubNoLocalOnlyTaskLists() + stubNoLocalOnlyTasks(42) + whenever(nowProvider.now()).thenReturn(mock()) + + // When + val spyRepository = spy(repository) + spyRepository.sync(true) + + // Then + verify(taskListsApi).list(maxResults = 100, null) + verify(taskListDao).getLocalOnlyTaskLists() + verify(taskListDao).deleteStaleTaskLists(emptyList()) + verify(nowProvider).now() + verify(spyRepository).cleanStaleTasks(emptyList()) + verifyNoMoreInteractions(taskListsApi, taskListDao, nowProvider) + verifyNoInteractions(taskDao, tasksApi) + } + + @Test + fun `when remote list then sync should create a local list`() = runTest { + // Given + val updatedDate = Clock.System.now() + stubRemoteTaskList(remoteId = "remoteId", title = "list", updatedDate = updatedDate) + whenever(taskListDao.getByRemoteId("remoteId")).thenReturn(null) + val localTaskList = LocalTaskList( + id = 0, + remoteId = "remoteId", + title = "list", + lastUpdateDate = updatedDate + ) + whenever(taskListDao.upsertAll(listOf(localTaskList))) + .thenReturn(listOf(42)) + stubNoRemoteTasks(taskListId = "remoteId") + stubNoLocalOnlyTaskLists() + stubNoLocalOnlyTasks(42) + + // When + repository.sync(false) + + // Then + verify(taskListDao).upsertAll(listOf(localTaskList)) + } + + @Test + fun `when remote list is renamed then sync should rename local counterpart`() = runTest { + // Given + val oldDate = Clock.System.now().minus(3.days) + val updatedDate = Clock.System.now() + stubRemoteTaskList(remoteId = "remoteId", title = "renamed list", updatedDate = updatedDate) + val localTaskList = LocalTaskList( + id = 42, + remoteId = "remoteId", + title = "list", + lastUpdateDate = oldDate, + sorting = LocalTaskList.Sorting.Title, + ) + whenever(taskListDao.getByRemoteId("remoteId")).thenReturn(localTaskList) + val localTaskListUpdated = localTaskList.copy(title = "renamed list", lastUpdateDate = updatedDate) + whenever(taskListDao.upsertAll(listOf(localTaskListUpdated))) + .thenReturn(listOf(42)) + stubNoRemoteTasks(taskListId = "remoteId") + stubNoLocalOnlyTaskLists() + stubNoLocalOnlyTasks(42) + + // When + repository.sync(false) + + // Then + verify(taskListDao).upsertAll(listOf(localTaskListUpdated)) + } + + //endregion + + //region Task pull + + @Test + fun `when remote task then sync should create a local task`() = runTest { + // Given + val updatedDate = Clock.System.now() + val localTaskListId = stubRemoteTaskListSynced(remoteId = "remoteListId") + val remoteTask = RemoteTask(id = "remoteTaskId", title = "task", updatedDate = updatedDate) + stubRemoteTasks(taskListId = "remoteListId", updatedMin = null, remoteTask) + whenever(taskDao.getByRemoteId("remoteTaskId")).thenReturn(null) + val localTask = remoteTask.asTaskEntity(parentListLocalId = localTaskListId, parentTaskLocalId = null, taskLocalId = null) + whenever(taskDao.upsertAll(listOf(localTask))) + .thenReturn(listOf(100)) + stubNoLocalOnlyTaskLists() + stubNoLocalOnlyTasks(localTaskListId) + + // When + repository.sync(false) + + // Then + verify(taskDao).upsertAll(listOf(localTask)) + } + + @Test + fun `when remote subtask then sync should create a local subtask`() = runTest { + // Given + val updatedDate = Clock.System.now() + val localTaskListId = stubRemoteTaskListSynced(remoteId = "remoteListId") + val remoteParentTask = RemoteTask(id = "remoteParentTaskId", title = "parent task", updatedDate = updatedDate) + val remoteChildTask = RemoteTask(id = "remoteChildTaskId", title = "child task", updatedDate = updatedDate, parent = "remoteParentTaskId") + stubRemoteTasks(taskListId = "remoteListId", updatedMin = null, remoteParentTask, remoteChildTask) + whenever(taskDao.getByRemoteId("remoteParentTaskId")).thenReturn(null) + val localParentTask = remoteParentTask.asTaskEntity(parentListLocalId = localTaskListId, parentTaskLocalId = null, taskLocalId = null) + whenever(taskDao.getByRemoteId("remoteChildTaskId")).thenReturn(null) + val localChildTask = remoteChildTask.asTaskEntity(parentListLocalId = localTaskListId, parentTaskLocalId = null, taskLocalId = null) + whenever(taskDao.upsertAll(listOf(localParentTask, localChildTask))) + .thenReturn(listOf(100, 101)) + stubNoLocalOnlyTaskLists() + stubNoLocalOnlyTasks(localTaskListId) + + // When + repository.sync(false) + + // Then + verify(taskDao).upsertAll(listOf(localParentTask, localChildTask)) + } + + @Test + fun `when sync 2 times then last sync time should be used to query remote tasks`() = runTest { + // Given + val updatedDate = Clock.System.now() + val syncTime1 = Clock.System.now() + val syncTime2 = Clock.System.now() + val localTaskListId = stubRemoteTaskListSynced(remoteId = "remoteListId") + val remoteParentTask = RemoteTask(id = "remoteParentTaskId", title = "parent task", updatedDate = updatedDate) + val remoteChildTask = RemoteTask(id = "remoteChildTaskId", title = "child task", updatedDate = updatedDate, parent = "remoteParentTaskId") + whenever(nowProvider.now()).thenReturn(syncTime1, syncTime2) + // first listing should provide no time + stubRemoteTasks(taskListId = "remoteListId", updatedMin = null, remoteParentTask, remoteChildTask) + whenever(taskDao.getByRemoteId("remoteParentTaskId")).thenReturn(null) + val localParentTask = remoteParentTask.asTaskEntity(parentListLocalId = localTaskListId, parentTaskLocalId = null, taskLocalId = null) + whenever(taskDao.getByRemoteId("remoteChildTaskId")).thenReturn(null) + val localChildTask = remoteChildTask.asTaskEntity(parentListLocalId = localTaskListId, parentTaskLocalId = null, taskLocalId = null) + whenever(taskDao.upsertAll(listOf(localParentTask, localChildTask))) + .thenReturn(listOf(100, 101)) + // second listing should provide previous sync time + stubNoRemoteTasks(taskListId = "remoteListId", updatedMin = syncTime1) + stubNoLocalOnlyTaskLists() + stubNoLocalOnlyTasks(localTaskListId) + + // When + repository.sync(false) + repository.sync(false) + + // Then + verify(taskDao).upsertAll(listOf(localParentTask, localChildTask)) + verify(tasksApi).list( + taskListId = "remoteListId", + showDeleted = false, + showHidden = true, + showCompleted = true, + maxResults = 100, + updatedMin = null, + completedMin = null, + completedMax = null, + dueMin = null, + dueMax = null, + ) + verify(tasksApi).list( + taskListId = "remoteListId", + showDeleted = false, + showHidden = true, + showCompleted = true, + maxResults = 100, + updatedMin = syncTime1, + completedMin = null, + completedMax = null, + dueMin = null, + dueMax = null, + ) + then(nowProvider).should(times(2)).now() + } + + @Test + fun `when remote task is updated then sync should rename local counterpart`() = runTest { + // Given + val oldDate = Clock.System.now().minus(3.days) + val updatedDate = Clock.System.now() + val localTaskListId = stubRemoteTaskListSynced(remoteId = "remoteListId") + val remoteTask = RemoteTask(id = "remoteTaskId", title = "renamed task", notes = "added notes", updatedDate = updatedDate) + stubRemoteTasks(taskListId = "remoteListId", updatedMin = null, remoteTask) + val localTask = LocalTask( + id = 100, + remoteId = remoteTask.id, + parentListLocalId = localTaskListId, + title = "task", + notes = "", + lastUpdateDate = oldDate, + position = "" + ) + whenever(taskDao.getByRemoteId("remoteTaskId")).thenReturn(localTask) + val localTaskUpdated = localTask.copy(title = remoteTask.title, notes = remoteTask.notes ?: "", lastUpdateDate = remoteTask.updatedDate) + whenever(taskDao.upsertAll(listOf(localTaskUpdated))) + .thenReturn(listOf(localTask.id)) + stubNoLocalOnlyTaskLists() + stubNoLocalOnlyTasks(localTaskListId) + + // When + repository.sync(false) + + // Then + verify(taskDao).upsertAll(listOf(localTaskUpdated)) + } + + @Ignore("TODO") + @Test + fun `when remote task is moved from one list to another then sync should reflect change to local counterparts`() = runTest { + } + + @Test + fun `when remote task is indented then sync should reflect change to local counterpart`() = runTest { + // Given + val oldDate = Clock.System.now().minus(3.days) + val updatedDate = Clock.System.now() + val localTaskListId = stubRemoteTaskListSynced(remoteId = "remoteListId") + val remoteParentTask = RemoteTask(id = "remoteParentTaskId", title = "task1", updatedDate = oldDate) + val remoteChildTask = RemoteTask(id = "remoteChildTaskId", title = "task2", updatedDate = updatedDate, parent = "remoteParentTaskId") + stubRemoteTasks(taskListId = "remoteListId", updatedMin = null, remoteParentTask, remoteChildTask) + val localTask1 = LocalTask( + id = 100, + remoteId = remoteParentTask.id, + parentListLocalId = localTaskListId, + parentTaskRemoteId = null, + parentTaskLocalId = null, + title = "task1", + notes = "", + lastUpdateDate = oldDate, + position = "" + ) + whenever(taskDao.getByRemoteId("remoteParentTaskId")).thenReturn(localTask1) + val localTask2 = LocalTask( + id = 101, + remoteId = remoteChildTask.id, + parentListLocalId = localTaskListId, + parentTaskRemoteId = null, + parentTaskLocalId = null, + title = "task2", + notes = "", + lastUpdateDate = oldDate, + position = "" + ) + whenever(taskDao.getByRemoteId("remoteChildTaskId")).thenReturn(localTask2) + val localTask2Updated = localTask2.copy( + parentTaskRemoteId = "remoteParentTaskId", + parentTaskLocalId = localTask1.id, + lastUpdateDate = updatedDate, + ) + whenever(taskDao.upsertAll(listOf(localTask1, localTask2Updated))) + .thenReturn(listOf(localTask1.id, localTask2.id)) + stubNoLocalOnlyTaskLists() + stubNoLocalOnlyTasks(localTaskListId) + + // When + repository.sync(false) + + // Then + verify(taskDao).upsertAll(listOf(localTask1, localTask2Updated)) + } + + @Ignore("TODO") + @Test + fun `when remote task is moved then sync should reflect change to local counterpart`() = runTest { + } + + //endregion + + //region TaskList push + + @Test + fun `when local list then sync should create a remote list`() = runTest { + // Given + val updatedDate = Clock.System.now() + stubNoRemoteTaskLists() + val localList = LocalTaskList(id = 42, remoteId = null, title = "list", lastUpdateDate = updatedDate) + whenever(taskListDao.getLocalOnlyTaskLists()).thenReturn(listOf(localList)) + val localListAsRemote = RemoteTaskList(title = localList.title, updatedDate = updatedDate) + val resultingRemoteList = localListAsRemote.copy(id = "remoteId", updatedDate = updatedDate.plus(1.seconds)) + whenever(taskListsApi.insert(localListAsRemote)) + .thenReturn(resultingRemoteList) + val localListUpdated = localList.copy(remoteId = resultingRemoteList.id, lastUpdateDate = resultingRemoteList.updatedDate) + whenever(taskListDao.upsertAll(listOf(localListUpdated))) + .thenReturn(listOf(localList.id)) + stubNoLocalOnlyTasks(localList.id) + whenever(nowProvider.now()).thenReturn(mock()) + + // When + repository.sync(false) + + // Then + verify(taskListsApi).insert(localListAsRemote) + verify(taskListDao).upsertAll(listOf(localListUpdated)) + } + + @Test + fun `when local list with task then sync should create a remote list and remote task`() = runTest { + // Given + val updatedDate = Clock.System.now() + stubNoRemoteTaskLists() + val localList = LocalTaskList(id = 42, remoteId = null, title = "list", lastUpdateDate = updatedDate) + whenever(taskListDao.getLocalOnlyTaskLists()).thenReturn(listOf(localList)) + val localListAsRemote = RemoteTaskList(title = localList.title, updatedDate = updatedDate) + val resultingRemoteList = localListAsRemote.copy(id = "remoteListId", updatedDate = updatedDate.plus(1.seconds)) + whenever(taskListsApi.insert(localListAsRemote)) + .thenReturn(resultingRemoteList) + val localListUpdated = localList.copy(remoteId = resultingRemoteList.id, lastUpdateDate = resultingRemoteList.updatedDate) + whenever(taskListDao.upsertAll(listOf(localListUpdated))) + .thenReturn(listOf(localList.id)) + val localTask = LocalTask( + id = 100, + remoteId = null, + title = "task", + lastUpdateDate = updatedDate, + parentListLocalId = localList.id, + parentTaskLocalId = null, + parentTaskRemoteId = null, + position = "00000000000000000000", + ) + whenever(taskDao.getLocalOnlyTasks(localList.id)).thenReturn(listOf(localTask)) + val localTaskAsRemote = localTask.asTask() + val resultingRemoteTask = localTaskAsRemote.copy( + id = "remoteTaskId", + updatedDate = updatedDate.plus(1.seconds), + ) + whenever(tasksApi.insert("remoteListId", localTaskAsRemote)) + .thenReturn(resultingRemoteTask) + val localTaskUpdated = localTask.copy(remoteId = resultingRemoteTask.id, lastUpdateDate = resultingRemoteTask.updatedDate) + whenever(taskDao.upsertAll(listOf(localTaskUpdated))) + .thenReturn(listOf(localTask.id)) + whenever(nowProvider.now()).thenReturn(mock()) + + // When + repository.sync(false) + + // Then + verify(taskListsApi).insert(localListAsRemote) + verify(taskListDao).upsertAll(listOf(localListUpdated)) + verify(tasksApi).insert("remoteListId", localTaskAsRemote) + verify(taskDao).upsertAll(listOf(localTaskUpdated)) + } + + @Test + fun `when synced list with local task then sync should create a remote task in remote list`() = runTest { + // Given + val updatedDate = Clock.System.now() + val localListId = stubRemoteTaskListSynced(remoteId = "remoteListId", title = "list", updatedDate = updatedDate) + stubNoRemoteTasks(taskListId = "remoteListId", updatedMin = null) + stubNoLocalOnlyTaskLists() + val localTask = LocalTask( + id = 100, + remoteId = null, + title = "task", + lastUpdateDate = updatedDate, + parentListLocalId = localListId, + parentTaskLocalId = null, + parentTaskRemoteId = null, + position = "00000000000000000000", + ) + whenever(taskDao.getLocalOnlyTasks(localListId)).thenReturn(listOf(localTask)) + val localTaskAsRemote = localTask.asTask() + val resultingRemoteTask = localTaskAsRemote.copy( + id = "remoteTaskId", + updatedDate = updatedDate.plus(1.seconds), + ) + whenever(tasksApi.insert("remoteListId", localTaskAsRemote)) + .thenReturn(resultingRemoteTask) + val localTaskUpdated = localTask.copy(remoteId = resultingRemoteTask.id, lastUpdateDate = resultingRemoteTask.updatedDate) + whenever(taskDao.upsertAll(listOf(localTaskUpdated))) + .thenReturn(listOf(localTask.id)) + whenever(nowProvider.now()).thenReturn(mock()) + + // When + repository.sync(false) + + // Then + verify(tasksApi).insert("remoteListId", localTaskAsRemote) + verify(taskDao).upsertAll(listOf(localTaskUpdated)) + } + + @Test + fun `when local list with task and subtask then sync should create a remote list, remote task and remote subtask`() = runTest { + // Given + val updatedDate = Clock.System.now() + stubNoRemoteTaskLists() + val localList = LocalTaskList(id = 42, remoteId = null, title = "list", lastUpdateDate = updatedDate) + whenever(taskListDao.getLocalOnlyTaskLists()).thenReturn(listOf(localList)) + val localListAsRemote = RemoteTaskList(title = localList.title, updatedDate = updatedDate) + val resultingRemoteList = localListAsRemote.copy(id = "remoteListId", updatedDate = updatedDate.plus(1.seconds)) + whenever(taskListsApi.insert(localListAsRemote)) + .thenReturn(resultingRemoteList) + val localListUpdated = localList.copy(remoteId = resultingRemoteList.id, lastUpdateDate = resultingRemoteList.updatedDate) + whenever(taskListDao.upsertAll(listOf(localListUpdated))) + .thenReturn(listOf(localList.id)) + val localParentTask = LocalTask( + id = 100, + remoteId = null, + title = "parent task", + lastUpdateDate = updatedDate, + parentListLocalId = localList.id, + parentTaskLocalId = null, + parentTaskRemoteId = null, + position = "00000000000000000000", + ) + val localChildTask = LocalTask( + id = 101, + remoteId = null, + title = "child task", + lastUpdateDate = updatedDate, + parentListLocalId = localList.id, + parentTaskLocalId = localParentTask.id, + parentTaskRemoteId = null, + position = "00000000000000000000", + ) + whenever(taskDao.getLocalOnlyTasks(localList.id)) + .thenReturn(listOf(localParentTask, localChildTask)) + val localParentTaskAsRemote = localParentTask.asTask() + val resultingRemoteParentTask = localParentTaskAsRemote.copy( + id = "remoteParentTaskId", + updatedDate = updatedDate.plus(1.seconds), + parent = null, + ) + val localChildTaskAsRemote = localChildTask.asTask() + val resultingRemoteChildTask = localChildTaskAsRemote.copy( + id = "remoteChildTaskId", + updatedDate = updatedDate.plus(2.seconds), + parent = resultingRemoteParentTask.id, + ) + val localParentTaskUpdated = + localParentTask.copy( + remoteId = resultingRemoteParentTask.id, + lastUpdateDate = resultingRemoteParentTask.updatedDate, + parentTaskLocalId = null, + parentTaskRemoteId = null, + ) + whenever(tasksApi.insert("remoteListId", localParentTaskAsRemote, null)) + .thenReturn(resultingRemoteParentTask) + val localChildTaskUpdated = localChildTask.copy( + remoteId = resultingRemoteChildTask.id, + lastUpdateDate = resultingRemoteChildTask.updatedDate, + parentTaskLocalId = localParentTask.id, + parentTaskRemoteId = resultingRemoteParentTask.id, + ) + whenever(tasksApi.insert("remoteListId", localChildTaskAsRemote, resultingRemoteParentTask.id)) + .thenReturn(resultingRemoteChildTask) + whenever(taskDao.upsertAll(listOf(localParentTaskUpdated, localChildTaskUpdated))) + .thenReturn(listOf(localParentTask.id, localChildTask.id)) + whenever(nowProvider.now()).thenReturn(mock()) + + // When + repository.sync(false) + + // Then + verify(taskListsApi).insert(localListAsRemote) + verify(taskListDao).upsertAll(listOf(localListUpdated)) + verify(tasksApi).insert(taskListId = "remoteListId", task = localParentTaskAsRemote, parentTaskId = null) + verify(tasksApi).insert(taskListId = "remoteListId", task = localChildTaskAsRemote, parentTaskId = resultingRemoteParentTask.id) + verify(taskDao).upsertAll(listOf(localParentTaskUpdated, localChildTaskUpdated)) + } + + //endregion + + //region Task push + + @Test + fun `when local task then sync should create a remote task`() = runTest { + // Given + val updatedDate = Clock.System.now() + val remoteTaskListId = "remoteListId" + val localTaskListId = stubRemoteTaskListSynced(remoteTaskListId) + stubNoRemoteTasks(taskListId = remoteTaskListId) + stubNoLocalOnlyTaskLists() + val localTask = LocalTask( + id = 100, + remoteId = null, + parentListLocalId = localTaskListId, + title = "task", + notes = "", + lastUpdateDate = updatedDate, + position = "00000000000000000000" + ) + whenever(taskDao.getLocalOnlyTasks(localTaskListId)).thenReturn(listOf(localTask)) + val remoteTask = localTask.asTask() + val remoteTaskUpdated = remoteTask.copy(id = "remoteTaskId", updatedDate = updatedDate.plus(1.seconds)) + whenever(tasksApi.insert(remoteTaskListId, remoteTask)).thenReturn(remoteTaskUpdated) + val localTaskUpdated = remoteTaskUpdated.asTaskEntity(localTaskListId, null, localTask.id) + + // When + repository.sync(false) + + // Then + verify(tasksApi).insert(remoteTaskListId, remoteTask) + verify(taskDao).upsertAll(listOf(localTaskUpdated)) + } + + @Test + fun `when local subtask then sync should create a remote subtask`() = runTest { + // Given + val updatedDate = Clock.System.now() + val remoteTaskListId = "remoteListId" + val localTaskListId = stubRemoteTaskListSynced(remoteTaskListId) + val remoteParentTask = RemoteTask(id = "remoteParentTaskId", title = "parent task", updatedDate = updatedDate) + stubRemoteTasks(taskListId = remoteTaskListId, updatedMin = null, remoteParentTask) + stubNoLocalOnlyTaskLists() + val localParentTask = LocalTask( + id = 100, + remoteId = remoteParentTask.id, + title = "parent task", + lastUpdateDate = updatedDate, + parentListLocalId = localTaskListId, + parentTaskLocalId = null, + parentTaskRemoteId = null, + position = "00000000000000000000", + ) + val localChildTask = LocalTask( + id = 101, + remoteId = null, + title = "child task", + lastUpdateDate = updatedDate, + parentListLocalId = localTaskListId, + parentTaskLocalId = localParentTask.id, + parentTaskRemoteId = null, + position = "00000000000000000000", + ) + whenever(taskDao.getLocalOnlyTasks(localTaskListId)).thenReturn(listOf(localChildTask)) + val remoteChildTask = localChildTask.asTask() + val remoteChildTaskUpdated = remoteChildTask.copy( + id = "remoteChildTaskId", + updatedDate = updatedDate.plus(1.seconds), + parent = remoteParentTask.id, + ) + whenever(tasksApi.insert(remoteTaskListId, remoteChildTask)).thenReturn(remoteChildTaskUpdated) + val localTaskUpdated = remoteChildTaskUpdated.asTaskEntity(localTaskListId, localParentTask.id, localChildTask.id) + + // When + repository.sync(false) + + // Then + verify(tasksApi).insert(remoteTaskListId, remoteChildTask) + verify(taskDao).upsertAll(listOf(localTaskUpdated)) + } + + @Test + fun `when local list sync fails then task sync should be ignored`() = runTest { + // Given + val updatedDate = Clock.System.now() + stubNoRemoteTaskLists() + val localList = LocalTaskList(id = 42, remoteId = null, title = "list", lastUpdateDate = updatedDate) + whenever(taskListDao.getLocalOnlyTaskLists()).thenReturn(listOf(localList)) + val remoteList = RemoteTaskList(title = localList.title, updatedDate = localList.lastUpdateDate) + whenever(taskListsApi.insert(remoteList)).thenThrow(ServerResponseException::class.java) + + // When + repository.sync(false) + + // Then + verify(taskListDao).getLocalOnlyTaskLists() + verifyNoMoreInteractions(taskListDao) + verifyNoInteractions(tasksApi, taskDao) + } + + @Test + fun `when local parent task sync fails then subtask sync should be ignored`() = runTest { + // Given + val updatedDate = Clock.System.now() + val remoteListId = "remoteListId" + val localListId = stubRemoteTaskListSynced(remoteListId) + whenever(taskListDao.getLocalOnlyTaskLists()).thenReturn(emptyList()) + stubNoRemoteTasks(remoteListId) + val localParentTask = LocalTask( + id = 100, + remoteId = null, + title = "parent task", + lastUpdateDate = updatedDate, + parentListLocalId = localListId, + parentTaskLocalId = null, + parentTaskRemoteId = null, + position = "00000000000000000000", + ) + val localChildTask = LocalTask( + id = 101, + remoteId = null, + title = "child task", + lastUpdateDate = updatedDate, + parentListLocalId = localListId, + parentTaskLocalId = localParentTask.id, + parentTaskRemoteId = null, + position = "00000000000000000000", + ) + whenever(taskDao.getLocalOnlyTasks(localListId)) + .thenReturn(listOf(localParentTask, localChildTask)) + val localParentTaskAsRemote = localParentTask.asTask() + whenever(tasksApi.insert("remoteListId", localParentTaskAsRemote, null)) + .thenThrow(ServerResponseException::class.java) + whenever(nowProvider.now()).thenReturn(mock()) + + // When + repository.sync(false) + + // Then + verify(tasksApi).list( + taskListId = "remoteListId", + showDeleted = false, + showHidden = true, + showCompleted = true, + maxResults = 100, + updatedMin = null, + completedMin = null, + completedMax = null, + dueMin = null, + dueMax = null, + ) + verify(tasksApi).insert(taskListId = "remoteListId", task = localParentTaskAsRemote, parentTaskId = null) + verifyNoMoreInteractions(tasksApi) + verify(taskDao).getLocalOnlyTasks(localListId) + verifyNoMoreInteractions(taskDao) + } + + //endregion + + //region Clean-up + + @Test + fun `when cleanup remote listing fails then sync should do nothing`() = runTest { + // Given + stubRemoteTaskListsFailure() + + // When + repository.cleanStaleTasks(null) + + // Then + verify(taskListsApi).list(maxResults = 100, null) + verifyNoMoreInteractions(taskListsApi) + verifyNoInteractions(taskListDao, taskDao, tasksApi, nowProvider) + } + + @Test + fun `when null remote list is provided then remote lists are fetch`() = runTest { + // Given + stubNoRemoteTaskLists() + + // When + repository.cleanStaleTasks(null) + + // Then + verify(taskListsApi).list(maxResults = 100, null) + verifyNoMoreInteractions(taskListsApi) + verify(taskListDao).deleteStaleTaskLists(emptyList()) + verifyNoMoreInteractions(taskListDao) + verifyNoInteractions(taskDao, tasksApi, nowProvider) + } + + @Test + fun `when remote lists then any local list not matching id is removed`() = runTest { + // Given + whenever(taskListDao.getByRemoteId("remoteId")).thenReturn(null) + + // When + val remoteTaskList = mock { + on { id } doReturn "remoteId" + } + repository.cleanStaleTasks(listOf(remoteTaskList)) + + // Then + verify(taskListDao).deleteStaleTaskLists(listOf("remoteId")) + verify(taskListDao).getByRemoteId("remoteId") + verifyNoMoreInteractions(taskListDao) + verifyNoInteractions(taskDao, taskListsApi, tasksApi, nowProvider) + } + + @Test + fun `when no more remote tasks then any local task with remote counterpart is removed`() = runTest { + // Given + stubRemoteTaskList(remoteId = "remoteId") + val localList = mock { + on { id } doReturn 42 + } + whenever(taskListDao.getByRemoteId("remoteId")).thenReturn(localList) + val response = mockRemoteTasksResponse(remoteTasks = emptyArray()) + whenever( + tasksApi.list( + taskListId = "remoteId", + showDeleted = true, + showHidden = true, + showCompleted = true, + maxResults = 100, + updatedMin = null, + completedMin = null, + completedMax = null, + dueMin = null, + dueMax = null, + ) + ).thenReturn(response) + val remoteTaskList = mock { + on { id } doReturn "remoteId" + } + + // When + repository.cleanStaleTasks(listOf(remoteTaskList)) + + // Then + verify(taskListDao).deleteStaleTaskLists(listOf("remoteId")) + verify(taskListDao).getByRemoteId("remoteId") + verifyNoMoreInteractions(taskListDao) + verify(taskDao).deleteStaleTasks(localList.id, emptyList()) + verifyNoMoreInteractions(taskDao) + verify(tasksApi).list( + taskListId = "remoteId", + showDeleted = true, + showHidden = true, + showCompleted = true, + maxResults = 100, + updatedMin = null, + completedMin = null, + completedMax = null, + dueMin = null, + dueMax = null, + ) + verifyNoMoreInteractions(tasksApi) + verifyNoInteractions(taskListsApi, nowProvider) + } + + @Test + fun `when remote tasks then any local task not matching id is removed`() = runTest { + // Given + stubRemoteTaskList(remoteId = "remoteListId") + val localList = mock { + on { id } doReturn 42 + } + whenever(taskListDao.getByRemoteId("remoteListId")).thenReturn(localList) + val remoteTask = mock { + on { id } doReturn "remoteTaskId" + } + val response = mockRemoteTasksResponse(remoteTask) + whenever( + tasksApi.list( + taskListId = "remoteListId", + showDeleted = true, + showHidden = true, + showCompleted = true, + maxResults = 100, + updatedMin = null, + completedMin = null, + completedMax = null, + dueMin = null, + dueMax = null, + ) + ).thenReturn(response) + val remoteTaskList = mock { + on { id } doReturn "remoteListId" + } + + // When + repository.cleanStaleTasks(listOf(remoteTaskList)) + + // Then + verify(taskListDao).deleteStaleTaskLists(listOf("remoteListId")) + verify(taskListDao).getByRemoteId("remoteListId") + verifyNoMoreInteractions(taskListDao) + verify(taskDao).deleteStaleTasks(localList.id, listOf(remoteTask.id)) + verifyNoMoreInteractions(taskDao) + verify(tasksApi).list( + taskListId = "remoteListId", + showDeleted = true, + showHidden = true, + showCompleted = true, + maxResults = 100, + updatedMin = null, + completedMin = null, + completedMax = null, + dueMin = null, + dueMax = null, + ) + verifyNoMoreInteractions(tasksApi) + verifyNoInteractions(taskListsApi, nowProvider) + } + + //endregion +} \ No newline at end of file