From 1ae94ddc2d8794b00d8927a90ec66e75fbd8ffdb Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Wed, 30 Oct 2024 17:15:35 +0100 Subject: [PATCH 01/21] More readable TaskRepository.toggleTaskCompletionState --- .../kotlin/net/opatry/tasks/data/TaskRepository.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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..2657f63e 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 @@ -528,9 +528,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, ) } From a0525985be3b82e3b795ae9d7d324fa6155a3d0e Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Wed, 30 Oct 2024 16:05:07 +0100 Subject: [PATCH 02/21] Fix local only tasks not being synced when task list is local only --- .../net/opatry/tasks/data/TaskRepositorySyncTest.kt | 10 +++++----- .../net/opatry/tasks/data/util/InMemoryTasksApi.kt | 2 +- .../kotlin/net/opatry/tasks/data/TaskRepository.kt | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) 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/TaskRepositorySyncTest.kt index e902f63e..3ef31858 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/TaskRepositorySyncTest.kt @@ -45,9 +45,7 @@ class TaskRepositorySyncTest { repository.sync() - assertEquals(1, taskListsApi.requestCount) assertContentEquals(listOf("list"), taskListsApi.requests) - assertEquals(2, tasksApi.requestCount) assertContentEquals(listOf("list", "list"), tasksApi.requests) val taskLists = repository.getTaskLists().firstOrNull() @@ -81,9 +79,10 @@ class TaskRepositorySyncTest { @Test fun `local only task lists are synced at next sync`() { - val taskListsApi = InMemoryTaskListsApi() + 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") @@ -94,8 +93,9 @@ class TaskRepositorySyncTest { // network is considered back, sync should trigger fetch & push requests taskListsApi.isNetworkAvailable = true repository.sync() - assertEquals(2, taskListsApi.requestCount) assertContentEquals(listOf("list", "insert"), taskListsApi.requests) + // FIXME not expected, to debug and remove + assertContentEquals(listOf("list", "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/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt b/tasks-core/src/commonMain/kotlin/net/opatry/tasks/data/TaskRepository.kt index 2657f63e..e02e1fb5 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 @@ -309,6 +309,7 @@ class TaskRepository( } if (remoteTaskList != null) { taskListDao.upsert(remoteTaskList.asTaskListEntity(localTaskList.id, localTaskList.sorting)) + taskListIds[localTaskList.id] = remoteTaskList.id } } taskListIds.forEach { (localListId, remoteListId) -> From cf5c4e9ba427e8e2fc0f78169f858f06572eb781 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Wed, 30 Oct 2024 16:48:54 +0100 Subject: [PATCH 03/21] Sync local only tasks & subtask in proper order to preserve hierarchy & sorting --- .../net/opatry/tasks/data/TaskRepository.kt | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) 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 e02e1fb5..e483bf49 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 @@ -325,20 +325,41 @@ class TaskRepository( taskDao.upsert(remoteTask.asTaskEntity(localListId, existingEntity?.id, parentTaskEntity?.id)) } taskDao.deleteStaleTasks(localListId, remoteTasks.map(Task::id)) - taskDao.getLocalOnlyTasks(localListId).onEach { localTask -> - val remoteTask = withContext(Dispatchers.IO) { - try { - tasksApi.insert(remoteListId, localTask.asTask()) - } catch (_: Exception) { - null - } - } - if (remoteTask != null) { - val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - taskDao.upsert(remoteTask.asTaskEntity(localListId, localTask.id, parentTaskEntity?.id)) + val localOnlyTasks = taskDao.getLocalOnlyTasks(localListId) + val sortedRootTasks = computeTaskPositions(localOnlyTasks.filter { it.parentTaskLocalId == null }) + var previousTaskId: String? = null + sortedRootTasks.onEach { localRootTask -> + val remoteTask = syncLocalTask(localListId, remoteListId, localRootTask, null, previousTaskId) + val sortedSubTasks = computeTaskPositions(localOnlyTasks.filter { it.parentTaskLocalId == localRootTask.id }) + var previousSubTaskId: String? = null + sortedSubTasks.onEach { localSubTask -> + val remoteSubTask = syncLocalTask(localListId, remoteListId, localSubTask, remoteTask?.id, previousSubTaskId) + previousSubTaskId = remoteSubTask?.id } + previousTaskId = remoteTask?.id + } + } + } + + private suspend fun syncLocalTask( + localTaskListId: Long, + remoteTaskListId: String, + localTask: TaskEntity, + parentTaskId: String?, + previousTaskId: String? + ): Task? { + val remoteTask = withContext(Dispatchers.IO) { + try { + tasksApi.insert(remoteTaskListId, localTask.asTask(), parentTaskId, previousTaskId) + } catch (_: Exception) { + null } } + if (remoteTask != null) { + val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } + taskDao.upsert(remoteTask.asTaskEntity(localTaskListId, localTask.id, parentTaskEntity?.id)) + } + return remoteTask } suspend fun createTaskList(title: String): Long { From 6e06ab1896791076e904a821a0f0df99a3798ccf Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Wed, 30 Oct 2024 17:59:56 +0100 Subject: [PATCH 04/21] Use upsertAll after sync instead of several individual inserts --- .../net/opatry/tasks/data/TaskRepository.kt | 64 ++++++++++--------- 1 file changed, 35 insertions(+), 29 deletions(-) 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 e483bf49..d403442d 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 @@ -319,47 +319,53 @@ class TaskRepository( val remoteTasks = withContext(Dispatchers.IO) { tasksApi.listAll(remoteListId, showHidden = true, showCompleted = true) } - remoteTasks.onEach { remoteTask -> + + val taskEntities = remoteTasks.map { remoteTask -> val existingEntity = taskDao.getByRemoteId(remoteTask.id) val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - taskDao.upsert(remoteTask.asTaskEntity(localListId, existingEntity?.id, parentTaskEntity?.id)) + remoteTask.asTaskEntity(localListId, existingEntity?.id, parentTaskEntity?.id) } + taskDao.upsertAll(taskEntities) + taskDao.deleteStaleTasks(localListId, remoteTasks.map(Task::id)) + val localOnlyTasks = taskDao.getLocalOnlyTasks(localListId) val sortedRootTasks = computeTaskPositions(localOnlyTasks.filter { it.parentTaskLocalId == null }) var previousTaskId: String? = null + val syncedTasks = mutableListOf() sortedRootTasks.onEach { localRootTask -> - val remoteTask = syncLocalTask(localListId, remoteListId, localRootTask, null, previousTaskId) - val sortedSubTasks = computeTaskPositions(localOnlyTasks.filter { it.parentTaskLocalId == localRootTask.id }) - var previousSubTaskId: String? = null - sortedSubTasks.onEach { localSubTask -> - val remoteSubTask = syncLocalTask(localListId, remoteListId, localSubTask, remoteTask?.id, previousSubTaskId) - previousSubTaskId = remoteSubTask?.id + val remoteRootTask = withContext(Dispatchers.IO) { + try { + tasksApi.insert(remoteListId, localRootTask.asTask(), null, previousTaskId).also { + syncedTasks.add(it.asTaskEntity(localListId, localRootTask.id, null)) + } + } catch (_: Exception) { + null + } } - previousTaskId = remoteTask?.id - } - } - } - private suspend fun syncLocalTask( - localTaskListId: Long, - remoteTaskListId: String, - localTask: TaskEntity, - parentTaskId: String?, - previousTaskId: String? - ): Task? { - val remoteTask = withContext(Dispatchers.IO) { - try { - tasksApi.insert(remoteTaskListId, localTask.asTask(), parentTaskId, previousTaskId) - } catch (_: Exception) { - null + // don't try syncing sub tasks if root task failed, it would break hierarchy on remote side + if (remoteRootTask != null) { + val sortedSubTasks = computeTaskPositions(localOnlyTasks.filter { it.parentTaskLocalId == localRootTask.id }) + var previousSubTaskId: String? = null + sortedSubTasks.onEach { localSubTask -> + val remoteSubTask = withContext(Dispatchers.IO) { + try { + tasksApi.insert(remoteListId, localSubTask.asTask(), remoteRootTask.id, previousSubTaskId).also { remoteTask -> + val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } + syncedTasks.add(remoteTask.asTaskEntity(localListId, localSubTask.id, parentTaskEntity?.id)) + } + } catch (_: Exception) { + null + } + } + previousSubTaskId = remoteSubTask?.id + } + } + previousTaskId = remoteRootTask?.id } + taskDao.upsertAll(syncedTasks) } - if (remoteTask != null) { - val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - taskDao.upsert(remoteTask.asTaskEntity(localTaskListId, localTask.id, parentTaskEntity?.id)) - } - return remoteTask } suspend fun createTaskList(title: String): Long { From 373445edb887f1711603517f7a81aff84aeb8759 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sat, 31 May 2025 14:25:12 +0200 Subject: [PATCH 05/21] Prepare inversion of task entity mapper arguments --- .../net/opatry/tasks/data/TaskRepository.kt | 84 ++++++++++++++++--- 1 file changed, 73 insertions(+), 11 deletions(-) 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 d403442d..1494ef21 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 @@ -323,7 +323,11 @@ class TaskRepository( val taskEntities = remoteTasks.map { remoteTask -> val existingEntity = taskDao.getByRemoteId(remoteTask.id) val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - remoteTask.asTaskEntity(localListId, existingEntity?.id, parentTaskEntity?.id) + remoteTask.asTaskEntity( + parentListLocalId = localListId, + taskLocalId = existingEntity?.id, + parentTaskLocalId = parentTaskEntity?.id, + ) } taskDao.upsertAll(taskEntities) @@ -336,8 +340,19 @@ class TaskRepository( sortedRootTasks.onEach { localRootTask -> val remoteRootTask = withContext(Dispatchers.IO) { try { - tasksApi.insert(remoteListId, localRootTask.asTask(), null, previousTaskId).also { - syncedTasks.add(it.asTaskEntity(localListId, localRootTask.id, null)) + tasksApi.insert( + taskListId = remoteListId, + task = localRootTask.asTask(), + parentTaskId = null, + previousTaskId = previousTaskId, + ).also { task -> + syncedTasks.add( + task.asTaskEntity( + parentListLocalId = localListId, + taskLocalId = localRootTask.id, + parentTaskLocalId = null, + ) + ) } } catch (_: Exception) { null @@ -351,9 +366,20 @@ class TaskRepository( sortedSubTasks.onEach { localSubTask -> val remoteSubTask = withContext(Dispatchers.IO) { try { - tasksApi.insert(remoteListId, localSubTask.asTask(), remoteRootTask.id, previousSubTaskId).also { remoteTask -> + tasksApi.insert( + taskListId = remoteListId, + task = localSubTask.asTask(), + parentTaskId = remoteRootTask.id, + previousTaskId = previousSubTaskId, + ).also { remoteTask -> val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - syncedTasks.add(remoteTask.asTaskEntity(localListId, localSubTask.id, parentTaskEntity?.id)) + syncedTasks.add( + remoteTask.asTaskEntity( + parentListLocalId = localListId, + taskLocalId = localSubTask.id, + parentTaskLocalId = parentTaskEntity?.id, + ) + ) } } catch (_: Exception) { null @@ -493,7 +519,13 @@ class TaskRepository( } } if (task != null) { - taskDao.upsert(task.asTaskEntity(taskListId, taskId, parentTaskId)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = taskListId, + taskLocalId = taskId, + parentTaskLocalId = parentTaskId, + ) + ) } } return taskId @@ -548,7 +580,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(updatedTaskEntity.parentListLocalId, taskId, updatedTaskEntity.parentTaskLocalId)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = updatedTaskEntity.parentListLocalId, + taskLocalId = taskId, + parentTaskLocalId = updatedTaskEntity.parentTaskLocalId, + ) + ) } } } @@ -654,7 +692,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(updatedTaskEntity.parentListLocalId, updatedTaskEntity.id, parentTaskEntity.id)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = updatedTaskEntity.parentListLocalId, + taskLocalId = updatedTaskEntity.id, + parentTaskLocalId = parentTaskEntity.id, + ) + ) } } } @@ -706,7 +750,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(updatedTaskEntity.parentListLocalId, updatedTaskEntity.id, null)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = updatedTaskEntity.parentListLocalId, + taskLocalId = updatedTaskEntity.id, + parentTaskLocalId = null, + ) + ) } } } @@ -744,7 +794,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(updatedTaskEntity.parentListLocalId, taskId, null)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = updatedTaskEntity.parentListLocalId, + taskLocalId = taskId, + parentTaskLocalId = null, + ) + ) } } } @@ -797,7 +853,13 @@ class TaskRepository( } if (task != null) { - taskDao.upsert(task.asTaskEntity(destinationListId, taskId, null)) + taskDao.upsert( + task.asTaskEntity( + parentListLocalId = destinationListId, + taskLocalId = taskId, + parentTaskLocalId = null, + ) + ) } } } From d5bc1042863cec2f83a215564421aa70415a4d18 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sat, 31 May 2025 14:26:30 +0200 Subject: [PATCH 06/21] Invert task entity mapper arguments to align with other similar APIs --- .../net/opatry/tasks/data/TaskRepository.kt | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) 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 1494ef21..27250f8b 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 @@ -60,12 +60,7 @@ 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 { +private fun Task.asTaskEntity(parentListLocalId: Long, parentTaskLocalId: Long?, taskLocalId: Long?): TaskEntity { return TaskEntity( id = taskLocalId ?: 0, remoteId = id, @@ -325,8 +320,8 @@ class TaskRepository( val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } remoteTask.asTaskEntity( parentListLocalId = localListId, - taskLocalId = existingEntity?.id, parentTaskLocalId = parentTaskEntity?.id, + taskLocalId = existingEntity?.id, ) } taskDao.upsertAll(taskEntities) @@ -349,8 +344,8 @@ class TaskRepository( syncedTasks.add( task.asTaskEntity( parentListLocalId = localListId, - taskLocalId = localRootTask.id, parentTaskLocalId = null, + taskLocalId = localRootTask.id, ) ) } @@ -376,8 +371,8 @@ class TaskRepository( syncedTasks.add( remoteTask.asTaskEntity( parentListLocalId = localListId, - taskLocalId = localSubTask.id, parentTaskLocalId = parentTaskEntity?.id, + taskLocalId = localSubTask.id, ) ) } @@ -522,8 +517,8 @@ class TaskRepository( taskDao.upsert( task.asTaskEntity( parentListLocalId = taskListId, - taskLocalId = taskId, parentTaskLocalId = parentTaskId, + taskLocalId = taskId, ) ) } @@ -583,8 +578,8 @@ class TaskRepository( taskDao.upsert( task.asTaskEntity( parentListLocalId = updatedTaskEntity.parentListLocalId, - taskLocalId = taskId, parentTaskLocalId = updatedTaskEntity.parentTaskLocalId, + taskLocalId = taskId, ) ) } @@ -695,8 +690,8 @@ class TaskRepository( taskDao.upsert( task.asTaskEntity( parentListLocalId = updatedTaskEntity.parentListLocalId, - taskLocalId = updatedTaskEntity.id, parentTaskLocalId = parentTaskEntity.id, + taskLocalId = updatedTaskEntity.id, ) ) } @@ -753,8 +748,8 @@ class TaskRepository( taskDao.upsert( task.asTaskEntity( parentListLocalId = updatedTaskEntity.parentListLocalId, - taskLocalId = updatedTaskEntity.id, parentTaskLocalId = null, + taskLocalId = updatedTaskEntity.id, ) ) } @@ -797,8 +792,8 @@ class TaskRepository( taskDao.upsert( task.asTaskEntity( parentListLocalId = updatedTaskEntity.parentListLocalId, - taskLocalId = taskId, parentTaskLocalId = null, + taskLocalId = taskId, ) ) } @@ -856,8 +851,8 @@ class TaskRepository( taskDao.upsert( task.asTaskEntity( parentListLocalId = destinationListId, - taskLocalId = taskId, parentTaskLocalId = null, + taskLocalId = taskId, ) ) } From 0c194bdbd687e94cfdf093b2943ede424839ec10 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sat, 31 May 2025 14:50:19 +0200 Subject: [PATCH 07/21] Avoid querying remote tasks for local only task list --- .../tasks/data/TaskRepositorySyncTest.kt | 3 +- .../net/opatry/tasks/data/TaskRepository.kt | 47 +++++++++++-------- 2 files changed, 28 insertions(+), 22 deletions(-) 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/TaskRepositorySyncTest.kt index 3ef31858..eaf5e73c 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/TaskRepositorySyncTest.kt @@ -94,8 +94,7 @@ class TaskRepositorySyncTest { taskListsApi.isNetworkAvailable = true repository.sync() assertContentEquals(listOf("list", "insert"), taskListsApi.requests) - // FIXME not expected, to debug and remove - assertContentEquals(listOf("list", "list"), tasksApi.requests) + assertContentEquals(listOf("list"), tasksApi.requests) } } } \ 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 27250f8b..ebf166db 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 @@ -251,6 +251,11 @@ fun Instant.asCompletedTaskPosition(): String { return sorting.toTaskPosition() } +private data class TaskListSyncAction( + val taskListId: String, + val fetchRemoteTasks: Boolean = true, +) + class TaskRepository( private val taskListDao: TaskListDao, private val taskDao: TaskDao, @@ -268,7 +273,7 @@ class TaskRepository( } suspend fun sync() { - val taskListIds = mutableMapOf() + val taskListSyncActions = mutableMapOf() val remoteTaskLists = withContext(Dispatchers.IO) { try { taskListsApi.listAll() @@ -291,7 +296,7 @@ class TaskRepository( 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 + taskListSyncActions[finalLocalId] = TaskListSyncAction(remoteTaskList.id) } taskListDao.deleteStaleTaskLists(remoteTaskLists.map(TaskList::id)) taskListDao.getLocalOnlyTaskLists().onEach { localTaskList -> @@ -304,29 +309,31 @@ class TaskRepository( } if (remoteTaskList != null) { taskListDao.upsert(remoteTaskList.asTaskListEntity(localTaskList.id, localTaskList.sorting)) - taskListIds[localTaskList.id] = remoteTaskList.id + taskListSyncActions[localTaskList.id] = TaskListSyncAction(remoteTaskList.id, fetchRemoteTasks = false) } } - taskListIds.forEach { (localListId, remoteListId) -> + taskListSyncActions.forEach { (localListId, actions) -> + val (taskListId, fetchRemoteTasks) = actions // 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) - } + if (fetchRemoteTasks) { + val remoteTasks = withContext(Dispatchers.IO) { + tasksApi.listAll(taskListId, showHidden = true, showCompleted = true) + } + val taskEntities = remoteTasks.map { remoteTask -> + val existingEntity = taskDao.getByRemoteId(remoteTask.id) + val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } + remoteTask.asTaskEntity( + parentListLocalId = localListId, + parentTaskLocalId = parentTaskEntity?.id, + taskLocalId = existingEntity?.id, + ) + } + taskDao.upsertAll(taskEntities) - val taskEntities = remoteTasks.map { remoteTask -> - val existingEntity = taskDao.getByRemoteId(remoteTask.id) - val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - remoteTask.asTaskEntity( - parentListLocalId = localListId, - parentTaskLocalId = parentTaskEntity?.id, - taskLocalId = existingEntity?.id, - ) + taskDao.deleteStaleTasks(localListId, remoteTasks.map(Task::id)) } - taskDao.upsertAll(taskEntities) - - taskDao.deleteStaleTasks(localListId, remoteTasks.map(Task::id)) val localOnlyTasks = taskDao.getLocalOnlyTasks(localListId) val sortedRootTasks = computeTaskPositions(localOnlyTasks.filter { it.parentTaskLocalId == null }) @@ -336,7 +343,7 @@ class TaskRepository( val remoteRootTask = withContext(Dispatchers.IO) { try { tasksApi.insert( - taskListId = remoteListId, + taskListId = taskListId, task = localRootTask.asTask(), parentTaskId = null, previousTaskId = previousTaskId, @@ -362,7 +369,7 @@ class TaskRepository( val remoteSubTask = withContext(Dispatchers.IO) { try { tasksApi.insert( - taskListId = remoteListId, + taskListId = taskListId, task = localSubTask.asTask(), parentTaskId = remoteRootTask.id, previousTaskId = previousSubTaskId, From 92175643fc2e9349510c05863ceaf182cc22cffe Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sat, 31 May 2025 17:17:00 +0200 Subject: [PATCH 08/21] Refactor TaskRepository.sync() as split sub sync routines --- .../net/opatry/tasks/data/TaskRepository.kt | 183 ++++++++++-------- 1 file changed, 101 insertions(+), 82 deletions(-) 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 ebf166db..4684c8c5 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 @@ -252,7 +252,7 @@ fun Instant.asCompletedTaskPosition(): String { } private data class TaskListSyncAction( - val taskListId: String, + val remoteTaskListId: String, val fetchRemoteTasks: Boolean = true, ) @@ -273,86 +273,122 @@ class TaskRepository( } suspend fun sync() { - val taskListSyncActions = mutableMapOf() val remoteTaskLists = withContext(Dispatchers.IO) { try { taskListsApi.listAll() } catch (_: Exception) { null } - } + } ?: return // most likely not internet, can't fetch data, nothing to sync - if (remoteTaskLists == null) { - // most likely not internet, can't fetch data, nothing to sync - return - } + val syncActions = syncRemoteTaskLists(remoteTaskLists) + syncLocalTaskLists(taskListDao.getLocalOnlyTaskLists()) + + syncTasks(syncActions) + } - 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) - taskListSyncActions[finalLocalId] = TaskListSyncAction(remoteTaskList.id) + private suspend fun syncRemoteTaskLists(remoteTaskLists: List): Map { + return buildMap { + 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 existingLocalList = taskListDao.getByRemoteId(remoteTaskList.id) + val localListToUpsert = remoteTaskList.asTaskListEntity( + localId = existingLocalList?.id, + sorting = existingLocalList?.sorting ?: TaskListEntity.Sorting.UserDefined + ) + val finalLocalId = taskListDao.upsert(localListToUpsert) + put(finalLocalId, TaskListSyncAction(remoteTaskList.id)) + } + + taskListDao.deleteStaleTaskLists(remoteTaskLists.map(TaskList::id)) } - taskListDao.deleteStaleTaskLists(remoteTaskLists.map(TaskList::id)) - taskListDao.getLocalOnlyTaskLists().onEach { localTaskList -> - val remoteTaskList = withContext(Dispatchers.IO) { - try { - taskListsApi.insert(TaskList(localTaskList.title)) - } catch (_: Exception) { - null + } + + private suspend fun syncLocalTaskLists(localTaskLists: List): Map { + return buildMap { + val syncedList = localTaskLists.mapNotNull { localTaskList -> + val remoteTaskList = withContext(Dispatchers.IO) { + try { + taskListsApi.insert(TaskList(localTaskList.title)) + } catch (_: Exception) { + null + } + } + remoteTaskList?.let { + put(localTaskList.id, TaskListSyncAction(remoteTaskList.id, fetchRemoteTasks = false)) + remoteTaskList.asTaskListEntity(localTaskList.id, localTaskList.sorting) } } - if (remoteTaskList != null) { - taskListDao.upsert(remoteTaskList.asTaskListEntity(localTaskList.id, localTaskList.sorting)) - taskListSyncActions[localTaskList.id] = TaskListSyncAction(remoteTaskList.id, fetchRemoteTasks = false) - } + + taskListDao.upsertAll(syncedList) } - taskListSyncActions.forEach { (localListId, actions) -> - val (taskListId, fetchRemoteTasks) = actions + } + + private suspend fun syncTasks(taskListSyncActions: Map) { + taskListSyncActions.forEach { (localListId, syncAction) -> + val (remoteTaskListId, fetchRemoteTasks) = syncAction // 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 if (fetchRemoteTasks) { val remoteTasks = withContext(Dispatchers.IO) { - tasksApi.listAll(taskListId, showHidden = true, showCompleted = true) + tasksApi.listAll(remoteTaskListId, showHidden = true, showCompleted = true) } - val taskEntities = remoteTasks.map { remoteTask -> - val existingEntity = taskDao.getByRemoteId(remoteTask.id) - val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } + val localTasks = remoteTasks.map { remoteTask -> + val existingLocalTask = taskDao.getByRemoteId(remoteTask.id) + val localParentTask = remoteTask.parent?.let { taskDao.getByRemoteId(it) } remoteTask.asTaskEntity( parentListLocalId = localListId, - parentTaskLocalId = parentTaskEntity?.id, - taskLocalId = existingEntity?.id, + parentTaskLocalId = localParentTask?.id, + taskLocalId = existingLocalTask?.id, ) } - taskDao.upsertAll(taskEntities) + taskDao.upsertAll(localTasks) taskDao.deleteStaleTasks(localListId, remoteTasks.map(Task::id)) } val localOnlyTasks = taskDao.getLocalOnlyTasks(localListId) - val sortedRootTasks = computeTaskPositions(localOnlyTasks.filter { it.parentTaskLocalId == null }) - var previousTaskId: String? = null - val syncedTasks = mutableListOf() - sortedRootTasks.onEach { localRootTask -> - val remoteRootTask = withContext(Dispatchers.IO) { + val syncedTasks = syncLocalTasks( + localTaskListId = localListId, + remoteTaskListId = remoteTaskListId, + localParentTaskId = null, + remoteParentTaskId = null, + tasks = localOnlyTasks, + ) + taskDao.upsertAll(syncedTasks) + } + } + + private suspend fun syncLocalTasks( + localTaskListId: Long, + remoteTaskListId: String, + localParentTaskId: Long?, + remoteParentTaskId: String?, + tasks: List + ): List { + val tasksToSync = computeTaskPositions(tasks.filter { it.parentTaskLocalId == localParentTaskId }) + var previousTaskId: String? = null + return buildList { + tasksToSync.onEach { localTask -> + val remoteTask = withContext(Dispatchers.IO) { try { tasksApi.insert( - taskListId = taskListId, - task = localRootTask.asTask(), - parentTaskId = null, + taskListId = remoteTaskListId, + task = localTask.asTask(), + parentTaskId = remoteParentTaskId, previousTaskId = previousTaskId, - ).also { task -> - syncedTasks.add( - task.asTaskEntity( - parentListLocalId = localListId, - parentTaskLocalId = null, - taskLocalId = localRootTask.id, + ).also { remoteTask -> + // ensure up to date local parent after sync + val localParentTask = remoteTask.parent?.let { taskDao.getByRemoteId(it) } + add( + remoteTask.asTaskEntity( + parentListLocalId = localTaskListId, + parentTaskLocalId = localParentTask?.id, + taskLocalId = localTask.id, ) ) } @@ -360,39 +396,22 @@ class TaskRepository( null } } - - // don't try syncing sub tasks if root task failed, it would break hierarchy on remote side - if (remoteRootTask != null) { - val sortedSubTasks = computeTaskPositions(localOnlyTasks.filter { it.parentTaskLocalId == localRootTask.id }) - var previousSubTaskId: String? = null - sortedSubTasks.onEach { localSubTask -> - val remoteSubTask = withContext(Dispatchers.IO) { - try { - tasksApi.insert( - taskListId = taskListId, - task = localSubTask.asTask(), - parentTaskId = remoteRootTask.id, - previousTaskId = previousSubTaskId, - ).also { remoteTask -> - val parentTaskEntity = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - syncedTasks.add( - remoteTask.asTaskEntity( - parentListLocalId = localListId, - parentTaskLocalId = parentTaskEntity?.id, - taskLocalId = localSubTask.id, - ) - ) - } - } catch (_: Exception) { - null - } - } - previousSubTaskId = remoteSubTask?.id - } + // FIXME if one of the task sync fails, it breaks sibling order + previousTaskId = remoteTask?.id + + // don't try syncing sub tasks if parent task failed, it would break hierarchy on remote side + if (remoteTask != null) { + addAll( + syncLocalTasks( + localTaskListId = localTaskListId, + remoteTaskListId = remoteTaskListId, + localParentTaskId = localTask.id, + remoteParentTaskId = remoteTask.id, + tasks = tasks, + ) + ) } - previousTaskId = remoteRootTask?.id } - taskDao.upsertAll(syncedTasks) } } From 0fd02e79bd1176c590eada28583d8769d4b09cff Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 01:32:16 +0200 Subject: [PATCH 09/21] Further refine sync --- .../net/opatry/tasks/data/TaskRepository.kt | 259 +++++++++--------- 1 file changed, 133 insertions(+), 126 deletions(-) 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 4684c8c5..22bbed4e 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 @@ -34,14 +34,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 +49,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,8 +60,8 @@ private fun TaskList.asTaskListEntity(localId: Long?, sorting: TaskListEntity.So ) } -private fun Task.asTaskEntity(parentListLocalId: Long, parentTaskLocalId: Long?, taskLocalId: Long?): TaskEntity { - return TaskEntity( +private fun RemoteTask.asTaskEntity(parentListLocalId: Long, parentTaskLocalId: Long?, taskLocalId: Long?): LocalTask { + return LocalTask( id = taskLocalId ?: 0, remoteId = id, parentListLocalId = parentListLocalId, @@ -78,18 +78,18 @@ private fun Task.asTaskEntity(parentListLocalId: Long, parentTaskLocalId: Long?, ) } -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) } } @@ -110,7 +110,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, @@ -125,14 +125,14 @@ private fun TaskEntity.asTaskDataModel(indent: Int, isParentTask: Boolean): Task ) } -private fun TaskEntity.asTask(): Task { - return Task( +private 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) @@ -141,12 +141,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) { @@ -160,11 +160,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 @@ -174,7 +174,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) } @@ -182,27 +182,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) } /** @@ -211,18 +211,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) } } @@ -231,7 +231,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" @@ -251,11 +251,6 @@ fun Instant.asCompletedTaskPosition(): String { return sorting.toTaskPosition() } -private data class TaskListSyncAction( - val remoteTaskListId: String, - val fetchRemoteTasks: Boolean = true, -) - class TaskRepository( private val taskListDao: TaskListDao, private val taskDao: TaskDao, @@ -281,86 +276,98 @@ class TaskRepository( } } ?: return // most likely not internet, can't fetch data, nothing to sync - val syncActions = syncRemoteTaskLists(remoteTaskLists) + syncLocalTaskLists(taskListDao.getLocalOnlyTaskLists()) + // update local lists from remote counterparts + val remoteSyncedTaskLists = remoteTaskLists.map { remoteTaskList -> + updateTaskListFromRemote(remoteTaskList) + } + taskListDao.deleteStaleTaskLists(remoteTaskLists.map(RemoteTaskList::id)) - syncTasks(syncActions) - } + // fetch remote tasks + remoteSyncedTaskLists.flatMap { taskList -> + taskList.remoteId?.let { remoteTaskListId -> + fetchRemoteTasks(taskList.id, remoteTaskListId) + } ?: emptyList() + }.also { syncedTasks -> + taskDao.upsertAll(syncedTasks) + } - private suspend fun syncRemoteTaskLists(remoteTaskLists: List): Map { - return buildMap { - 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 existingLocalList = taskListDao.getByRemoteId(remoteTaskList.id) - val localListToUpsert = remoteTaskList.asTaskListEntity( - localId = existingLocalList?.id, - sorting = existingLocalList?.sorting ?: TaskListEntity.Sorting.UserDefined + // upload local only lists + val localOnlySyncedTaskLists = taskListDao.getLocalOnlyTaskLists().mapNotNull { localTaskList -> + syncLocalTaskList(localTaskList) + }.also { syncedTaskLists -> + taskListDao.upsertAll(syncedTaskLists) + } + + // upload local only tasks + val allTaskLists = remoteSyncedTaskLists + localOnlySyncedTaskLists + allTaskLists.flatMap { taskList -> + val localOnlyTasks = taskDao.getLocalOnlyTasks(taskList.id) + taskList.remoteId?.let { remoteTaskListId -> + syncLocalTasks( + localTaskListId = taskList.id, + remoteTaskListId = remoteTaskListId, + localParentTaskId = null, + remoteParentTaskId = null, + localOnlyTasks, ) - val finalLocalId = taskListDao.upsert(localListToUpsert) - put(finalLocalId, TaskListSyncAction(remoteTaskList.id)) - } - - taskListDao.deleteStaleTaskLists(remoteTaskLists.map(TaskList::id)) + } ?: emptyList() + }.also { syncedTasks -> + taskDao.upsertAll(syncedTasks) } } - private suspend fun syncLocalTaskLists(localTaskLists: List): Map { - return buildMap { - val syncedList = localTaskLists.mapNotNull { localTaskList -> - val remoteTaskList = withContext(Dispatchers.IO) { - try { - taskListsApi.insert(TaskList(localTaskList.title)) - } catch (_: Exception) { - null - } - } - remoteTaskList?.let { - put(localTaskList.id, TaskListSyncAction(remoteTaskList.id, fetchRemoteTasks = false)) - remoteTaskList.asTaskListEntity(localTaskList.id, localTaskList.sorting) - } - } - - taskListDao.upsertAll(syncedList) - } + private suspend fun updateTaskListFromRemote(remoteTaskList: RemoteTaskList): LocalTaskList { + // 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 existingLocalList = taskListDao.getByRemoteId(remoteTaskList.id) + val localListToUpsert = remoteTaskList.asTaskListEntity( + localId = existingLocalList?.id, + sorting = existingLocalList?.sorting ?: LocalTaskList.Sorting.UserDefined + ) + val finalLocalId = taskListDao.upsert(localListToUpsert) + return localListToUpsert.copy(id = finalLocalId) } - private suspend fun syncTasks(taskListSyncActions: Map) { - taskListSyncActions.forEach { (localListId, syncAction) -> - val (remoteTaskListId, fetchRemoteTasks) = syncAction - // 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 - if (fetchRemoteTasks) { - val remoteTasks = withContext(Dispatchers.IO) { - tasksApi.listAll(remoteTaskListId, showHidden = true, showCompleted = true) - } - val localTasks = remoteTasks.map { remoteTask -> - val existingLocalTask = taskDao.getByRemoteId(remoteTask.id) - val localParentTask = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - remoteTask.asTaskEntity( - parentListLocalId = localListId, - parentTaskLocalId = localParentTask?.id, - taskLocalId = existingLocalTask?.id, - ) - } - taskDao.upsertAll(localTasks) - - taskDao.deleteStaleTasks(localListId, remoteTasks.map(Task::id)) + private suspend fun syncLocalTaskList(localTaskList: LocalTaskList): LocalTaskList? { + return withContext(Dispatchers.IO) { + try { + taskListsApi.insert(RemoteTaskList(localTaskList.title)) + } catch (_: Exception) { + null } + }?.asTaskListEntity(localTaskList.id, localTaskList.sorting) + } - val localOnlyTasks = taskDao.getLocalOnlyTasks(localListId) - val syncedTasks = syncLocalTasks( - localTaskListId = localListId, - remoteTaskListId = remoteTaskListId, - localParentTaskId = null, - remoteParentTaskId = null, - tasks = localOnlyTasks, + private suspend fun fetchRemoteTasks(localTaskListId: Long, remoteTaskListId: String): List { + // 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(remoteTaskListId, showHidden = true, showCompleted = true) + } + val syncedTasks = remoteTasks.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, ) - taskDao.upsertAll(syncedTasks) + }.let { syncedTasks -> + // need to upsert BEFORE deleting stale tasks to reason on an up to date state + // TODO revisiting the deleteStaleTasks query, we might be able to avoid that + // so that caller can do a single upsertAll for ALL lists in a single shot + val taskIds = taskDao.upsertAll(syncedTasks) + // update task ids following upsertAll + syncedTasks.zip(taskIds) { task, id -> task.copy(id = id) } } + + taskDao.deleteStaleTasks(localTaskListId, remoteTasks.map(RemoteTask::id)) + + return syncedTasks } private suspend fun syncLocalTasks( @@ -368,8 +375,8 @@ class TaskRepository( remoteTaskListId: String, localParentTaskId: Long?, remoteParentTaskId: String?, - tasks: List - ): List { + tasks: List + ): List { val tasksToSync = computeTaskPositions(tasks.filter { it.parentTaskLocalId == localParentTaskId }) var previousTaskId: String? = null return buildList { @@ -417,16 +424,16 @@ class TaskRepository( 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 } @@ -459,7 +466,7 @@ class TaskRepository( try { taskListsApi.update( taskListEntity.remoteId, - TaskList( + RemoteTaskList( id = taskListEntity.remoteId, title = taskListEntity.title, updatedDate = taskListEntity.lastUpdateDate @@ -479,7 +486,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 -> @@ -499,9 +506,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) @@ -514,7 +521,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, @@ -579,7 +586,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 From bc9d2f4cbf5e9bcd83affd573f95513884ea8c56 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 10:48:32 +0200 Subject: [PATCH 10/21] Isolate cleanup of stale local data in its own function (to be called later when its appropriate) --- .../net/opatry/tasks/data/TaskRepository.kt | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) 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 22bbed4e..92a249eb 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 @@ -267,6 +267,33 @@ class TaskRepository( } } + suspend fun cleanStaleTasks() { + val remoteTaskListIds = withContext(Dispatchers.IO) { + try { + taskListsApi.listAll() + } catch (_: Exception) { + null + } + }?.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() { val remoteTaskLists = withContext(Dispatchers.IO) { try { @@ -280,7 +307,6 @@ class TaskRepository( val remoteSyncedTaskLists = remoteTaskLists.map { remoteTaskList -> updateTaskListFromRemote(remoteTaskList) } - taskListDao.deleteStaleTaskLists(remoteTaskLists.map(RemoteTaskList::id)) // fetch remote tasks remoteSyncedTaskLists.flatMap { taskList -> @@ -345,10 +371,9 @@ class TaskRepository( // 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) { + withContext(Dispatchers.IO) { tasksApi.listAll(remoteTaskListId, showHidden = true, showCompleted = true) - } - val syncedTasks = remoteTasks.map { remoteTask -> + }.map { remoteTask -> val existingLocalTask = taskDao.getByRemoteId(remoteTask.id) val localParentTask = remoteTask.parent?.let { taskDao.getByRemoteId(it) } remoteTask.asTaskEntity( @@ -357,17 +382,10 @@ class TaskRepository( taskLocalId = existingLocalTask?.id, ) }.let { syncedTasks -> - // need to upsert BEFORE deleting stale tasks to reason on an up to date state - // TODO revisiting the deleteStaleTasks query, we might be able to avoid that - // so that caller can do a single upsertAll for ALL lists in a single shot val taskIds = taskDao.upsertAll(syncedTasks) // update task ids following upsertAll syncedTasks.zip(taskIds) { task, id -> task.copy(id = id) } } - - taskDao.deleteStaleTasks(localTaskListId, remoteTasks.map(RemoteTask::id)) - - return syncedTasks } private suspend fun syncLocalTasks( From ea4848ec42a83e63dcf5a49504112141c3543d6a Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 10:49:30 +0200 Subject: [PATCH 11/21] Store (in memory for now) last sync and leverage it when listing tasks to minimize sync to DB work --- .../net/opatry/tasks/data/TaskRepository.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 92a249eb..b9c07858 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 @@ -258,6 +258,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() @@ -340,6 +343,8 @@ class TaskRepository( }.also { syncedTasks -> taskDao.upsertAll(syncedTasks) } + + lastSync = nowProvider.now() } private suspend fun updateTaskListFromRemote(remoteTaskList: RemoteTaskList): LocalTaskList { @@ -368,11 +373,14 @@ class TaskRepository( } private suspend fun fetchRemoteTasks(localTaskListId: Long, remoteTaskListId: String): List { - // 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 - withContext(Dispatchers.IO) { - tasksApi.listAll(remoteTaskListId, showHidden = true, showCompleted = true) + 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) } From 7a144dd9ee281ba23cb406832a0f3d2d4857cbad Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 10:53:11 +0200 Subject: [PATCH 12/21] Trigger cleanup of stale tasks after first sync (simple approach to begin with) --- .../kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt | 4 ++-- .../kotlin/net/opatry/tasks/data/TaskRepository.kt | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) 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/TaskRepositorySyncTest.kt index eaf5e73c..4e12be35 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/TaskRepositorySyncTest.kt @@ -43,7 +43,7 @@ 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) assertContentEquals(listOf("list"), taskListsApi.requests) assertContentEquals(listOf("list", "list"), tasksApi.requests) @@ -92,7 +92,7 @@ class TaskRepositorySyncTest { // network is considered back, sync should trigger fetch & push requests taskListsApi.isNetworkAvailable = true - repository.sync() + repository.sync(cleanStaleTasks = false) assertContentEquals(listOf("list", "insert"), taskListsApi.requests) assertContentEquals(listOf("list"), tasksApi.requests) } 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 b9c07858..d4465b37 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 @@ -297,7 +297,7 @@ class TaskRepository( taskListDao.deleteStaleTaskLists(remoteTaskListIds) } - suspend fun sync() { + suspend fun sync(cleanStaleTasks: Boolean = lastSync == null) { val remoteTaskLists = withContext(Dispatchers.IO) { try { taskListsApi.listAll() @@ -345,6 +345,10 @@ class TaskRepository( } lastSync = nowProvider.now() + + if (cleanStaleTasks) { + cleanStaleTasks() + } } private suspend fun updateTaskListFromRemote(remoteTaskList: RemoteTaskList): LocalTaskList { From 5b48db55e940165e66ac618197528b11b360bcd9 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 11:26:10 +0200 Subject: [PATCH 13/21] Maximize the use of upsertAll whenever possible during sync --- .../net/opatry/tasks/data/TaskRepository.kt | 41 ++++++++----------- 1 file changed, 16 insertions(+), 25 deletions(-) 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 d4465b37..f0b3b4b8 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 @@ -307,17 +307,27 @@ class TaskRepository( } ?: return // most likely not internet, can't fetch data, nothing to sync // update local lists from remote counterparts - val remoteSyncedTaskLists = remoteTaskLists.map { remoteTaskList -> - updateTaskListFromRemote(remoteTaskList) + val syncedTaskLists = remoteTaskLists.map { remoteTaskList -> + val existingLocalList = taskListDao.getByRemoteId(remoteTaskList.id) + remoteTaskList.asTaskListEntity( + localId = existingLocalList?.id, + sorting = existingLocalList?.sorting ?: LocalTaskList.Sorting.UserDefined + ) + }.let { lists -> + val finalListIds = taskListDao.upsertAll(lists) + // update list ids following upsertAll + lists.zip(finalListIds) { list, finalId -> list.copy(id = finalId) } } // fetch remote tasks - remoteSyncedTaskLists.flatMap { taskList -> + syncedTaskLists.flatMap { taskList -> taskList.remoteId?.let { remoteTaskListId -> fetchRemoteTasks(taskList.id, remoteTaskListId) } ?: emptyList() - }.also { syncedTasks -> - taskDao.upsertAll(syncedTasks) + }.let { syncedTasks -> + val finalTaskIds = taskDao.upsertAll(syncedTasks) + // update list ids following upsertAll + syncedTasks.zip(finalTaskIds) { task, finalId -> task.copy(id = finalId) } } // upload local only lists @@ -328,7 +338,7 @@ class TaskRepository( } // upload local only tasks - val allTaskLists = remoteSyncedTaskLists + localOnlySyncedTaskLists + val allTaskLists = syncedTaskLists + localOnlySyncedTaskLists allTaskLists.flatMap { taskList -> val localOnlyTasks = taskDao.getLocalOnlyTasks(taskList.id) taskList.remoteId?.let { remoteTaskListId -> @@ -351,21 +361,6 @@ class TaskRepository( } } - private suspend fun updateTaskListFromRemote(remoteTaskList: RemoteTaskList): LocalTaskList { - // 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 existingLocalList = taskListDao.getByRemoteId(remoteTaskList.id) - val localListToUpsert = remoteTaskList.asTaskListEntity( - localId = existingLocalList?.id, - sorting = existingLocalList?.sorting ?: LocalTaskList.Sorting.UserDefined - ) - val finalLocalId = taskListDao.upsert(localListToUpsert) - return localListToUpsert.copy(id = finalLocalId) - } - private suspend fun syncLocalTaskList(localTaskList: LocalTaskList): LocalTaskList? { return withContext(Dispatchers.IO) { try { @@ -393,10 +388,6 @@ class TaskRepository( parentTaskLocalId = localParentTask?.id, taskLocalId = existingLocalTask?.id, ) - }.let { syncedTasks -> - val taskIds = taskDao.upsertAll(syncedTasks) - // update task ids following upsertAll - syncedTasks.zip(taskIds) { task, id -> task.copy(id = id) } } } From c1bbdd6d6651b31cc7d3b48e15a5e4dc79c7d881 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 11:28:15 +0200 Subject: [PATCH 14/21] Improve sync test names --- .../kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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/TaskRepositorySyncTest.kt index 4e12be35..81ca1ad4 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/TaskRepositorySyncTest.kt @@ -35,7 +35,7 @@ import kotlin.test.assertNotNull class TaskRepositorySyncTest { @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")) @@ -64,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 -> @@ -78,7 +78,7 @@ class TaskRepositorySyncTest { } @Test - fun `local only task lists are synced at next sync`() { + fun `when there are local only task lists then sync should upload them`() { val taskListsApi = InMemoryTaskListsApi("Task list") val tasksApi = InMemoryTasksApi() From e96ec330aa6551e57feee1a98ddb247c835d1140 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 14:33:48 +0200 Subject: [PATCH 15/21] Add mockito-kotlin to mockito bundle --- gradle/libs.versions.toml | 3 +++ 1 file changed, 3 insertions(+) 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] From 27273cb1323a761528c4dbd8f99221057969d72d Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 14:34:16 +0200 Subject: [PATCH 16/21] Add test dependencies to tasks-core for sync test using mocks --- tasks-core/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) 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 From 1e19e24e98a4f92b8eeb3a4d024558e0baa04309 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 14:35:24 +0200 Subject: [PATCH 17/21] Revise TaskRepository.sync to minimize mock requirements --- .../net/opatry/tasks/data/TaskRepository.kt | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) 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 f0b3b4b8..13805244 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 @@ -313,36 +313,38 @@ class TaskRepository( localId = existingLocalList?.id, sorting = existingLocalList?.sorting ?: LocalTaskList.Sorting.UserDefined ) - }.let { lists -> + }.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() - // fetch remote tasks + // pull remote tasks syncedTaskLists.flatMap { taskList -> taskList.remoteId?.let { remoteTaskListId -> - fetchRemoteTasks(taskList.id, remoteTaskListId) + pullRemoteTasks(taskList.id, remoteTaskListId) } ?: emptyList() - }.let { syncedTasks -> - val finalTaskIds = taskDao.upsertAll(syncedTasks) - // update list ids following upsertAll - syncedTasks.zip(finalTaskIds) { task, finalId -> task.copy(id = finalId) } + }.also { syncedTasks -> + if (syncedTasks.isNotEmpty()) { + taskDao.upsertAll(syncedTasks) + } } - // upload local only lists + // push local only lists val localOnlySyncedTaskLists = taskListDao.getLocalOnlyTaskLists().mapNotNull { localTaskList -> - syncLocalTaskList(localTaskList) - }.also { syncedTaskLists -> - taskListDao.upsertAll(syncedTaskLists) - } + 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() - // upload local only tasks + // push local only tasks val allTaskLists = syncedTaskLists + localOnlySyncedTaskLists allTaskLists.flatMap { taskList -> val localOnlyTasks = taskDao.getLocalOnlyTasks(taskList.id) taskList.remoteId?.let { remoteTaskListId -> - syncLocalTasks( + pushLocalTasks( localTaskListId = taskList.id, remoteTaskListId = remoteTaskListId, localParentTaskId = null, @@ -351,7 +353,9 @@ class TaskRepository( ) } ?: emptyList() }.also { syncedTasks -> - taskDao.upsertAll(syncedTasks) + if (syncedTasks.isNotEmpty()) { + taskDao.upsertAll(syncedTasks) + } } lastSync = nowProvider.now() @@ -361,7 +365,7 @@ class TaskRepository( } } - private suspend fun syncLocalTaskList(localTaskList: LocalTaskList): LocalTaskList? { + private suspend fun pushLocalTaskList(localTaskList: LocalTaskList): LocalTaskList? { return withContext(Dispatchers.IO) { try { taskListsApi.insert(RemoteTaskList(localTaskList.title)) @@ -371,7 +375,7 @@ class TaskRepository( }?.asTaskListEntity(localTaskList.id, localTaskList.sorting) } - private suspend fun fetchRemoteTasks(localTaskListId: Long, remoteTaskListId: String): List { + private suspend fun pullRemoteTasks(localTaskListId: Long, remoteTaskListId: String): List { return withContext(Dispatchers.IO) { tasksApi.listAll( taskListId = remoteTaskListId, @@ -391,7 +395,7 @@ class TaskRepository( } } - private suspend fun syncLocalTasks( + private suspend fun pushLocalTasks( localTaskListId: Long, remoteTaskListId: String, localParentTaskId: Long?, @@ -430,7 +434,7 @@ class TaskRepository( // don't try syncing sub tasks if parent task failed, it would break hierarchy on remote side if (remoteTask != null) { addAll( - syncLocalTasks( + pushLocalTasks( localTaskListId = localTaskListId, remoteTaskListId = remoteTaskListId, localParentTaskId = localTask.id, From 0db463dcacce60578154ca90ffe76f9752f8e3e0 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 17:16:39 +0200 Subject: [PATCH 18/21] Honor local only list update date when pushing remotely --- .../kotlin/net/opatry/tasks/data/TaskRepository.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 13805244..732f9017 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 @@ -368,7 +368,12 @@ class TaskRepository( private suspend fun pushLocalTaskList(localTaskList: LocalTaskList): LocalTaskList? { return withContext(Dispatchers.IO) { try { - taskListsApi.insert(RemoteTaskList(localTaskList.title)) + taskListsApi.insert( + RemoteTaskList( + title = localTaskList.title, + updatedDate = localTaskList.lastUpdateDate + ) + ) } catch (_: Exception) { null } From ab01b883b59c284fad9c9c12b3ea9a65d92bd5c5 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 22:58:00 +0200 Subject: [PATCH 19/21] Reuse same task list to clean stale tasks from initial sync --- .../net/opatry/tasks/data/TaskRepository.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 732f9017..d4bd4be8 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 @@ -270,13 +270,17 @@ class TaskRepository( } } - suspend fun cleanStaleTasks() { - val remoteTaskListIds = withContext(Dispatchers.IO) { - try { - taskListsApi.listAll() - } catch (_: Exception) { - null + 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 -> @@ -361,7 +365,7 @@ class TaskRepository( lastSync = nowProvider.now() if (cleanStaleTasks) { - cleanStaleTasks() + cleanStaleTasks(remoteTaskLists) } } From 7aab903d0b636f872b559a9e612e92becb26989e Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 2 Jun 2025 21:53:02 +0200 Subject: [PATCH 20/21] Allow syncing local only subtask even when parent task is already synced --- .../net/opatry/tasks/data/TaskRepository.kt | 47 +++++++------------ 1 file changed, 18 insertions(+), 29 deletions(-) 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 d4bd4be8..421ae16c 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 @@ -351,8 +351,6 @@ class TaskRepository( pushLocalTasks( localTaskListId = taskList.id, remoteTaskListId = remoteTaskListId, - localParentTaskId = null, - remoteParentTaskId = null, localOnlyTasks, ) } ?: emptyList() @@ -407,53 +405,44 @@ class TaskRepository( private suspend fun pushLocalTasks( localTaskListId: Long, remoteTaskListId: String, - localParentTaskId: Long?, - remoteParentTaskId: String?, tasks: List ): List { - val tasksToSync = computeTaskPositions(tasks.filter { it.parentTaskLocalId == localParentTaskId }) - var previousTaskId: String? = null - return buildList { + // 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( taskListId = remoteTaskListId, task = localTask.asTask(), - parentTaskId = remoteParentTaskId, + parentTaskId = syncedTasks[localParentTaskId]?.remoteId, previousTaskId = previousTaskId, ).also { remoteTask -> - // ensure up to date local parent after sync - val localParentTask = remoteTask.parent?.let { taskDao.getByRemoteId(it) } - add( - remoteTask.asTaskEntity( - parentListLocalId = localTaskListId, - parentTaskLocalId = localParentTask?.id, - taskLocalId = localTask.id, - ) + syncedTasks[localTask.id] = remoteTask.asTaskEntity( + parentListLocalId = localTaskListId, + parentTaskLocalId = localParentTaskId, + taskLocalId = localTask.id, ) } } catch (_: Exception) { + syncFailedTaskIds += localTask.id null } } // FIXME if one of the task sync fails, it breaks sibling order previousTaskId = remoteTask?.id - - // don't try syncing sub tasks if parent task failed, it would break hierarchy on remote side - if (remoteTask != null) { - addAll( - pushLocalTasks( - localTaskListId = localTaskListId, - remoteTaskListId = remoteTaskListId, - localParentTaskId = localTask.id, - remoteParentTaskId = remoteTask.id, - tasks = tasks, - ) - ) - } } } + return syncedTasks.values.toList() } suspend fun createTaskList(title: String): Long { From 6b0836b1856f7d50376c9f74bf72a692a4fedb4f Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Sun, 1 Jun 2025 14:35:45 +0200 Subject: [PATCH 21/21] Add TaskRepositorySyncTest in `:tasks-core` with Mockito --- ...t => TaskRepositorySyncIntegrationTest.kt} | 2 +- .../net/opatry/tasks/data/TaskRepository.kt | 7 +- .../tasks/data/TaskRepositorySyncTest.kt | 1005 +++++++++++++++++ 3 files changed, 1011 insertions(+), 3 deletions(-) rename tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/{TaskRepositorySyncTest.kt => TaskRepositorySyncIntegrationTest.kt} (99%) create mode 100644 tasks-core/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt 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 99% 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 81ca1ad4..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,7 +33,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull -class TaskRepositorySyncTest { +class TaskRepositorySyncIntegrationTest { @Test fun `when remote task lists with tasks then sync should store data locally`() { val taskListsApi = InMemoryTaskListsApi("My tasks", "Other tasks") 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 421ae16c..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 @@ -60,7 +61,8 @@ private fun RemoteTaskList.asTaskListEntity(localId: Long?, sorting: LocalTaskLi ) } -private fun RemoteTask.asTaskEntity(parentListLocalId: Long, parentTaskLocalId: Long?, taskLocalId: Long?): LocalTask { +@VisibleForTesting +internal fun RemoteTask.asTaskEntity(parentListLocalId: Long, parentTaskLocalId: Long?, taskLocalId: Long?): LocalTask { return LocalTask( id = taskLocalId ?: 0, remoteId = id, @@ -125,7 +127,8 @@ private fun LocalTask.asTaskDataModel(indent: Int, isParentTask: Boolean): TaskD ) } -private fun LocalTask.asTask(): RemoteTask { +@VisibleForTesting +internal fun LocalTask.asTask(): RemoteTask { return RemoteTask( id = remoteId ?: "", title = title, 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