feat(kanban): synchronize task mutations across columns and guard concurrent moves
This commit is contained in:
1 parent
c57e056d71
commit
6fd697f2b0
2 files changed
+339
-14
No files matched your search
@@ -0,0 +1,247 @@
|
||||
package com.m57.hermescontrol.ui.kanban
|
||||
|
||||
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
|
||||
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||
import com.m57.hermescontrol.data.model.KanbanTask
|
||||
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class KanbanMutationTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||
private val preferences = InMemoryKanbanPreferencesStore()
|
||||
|
||||
private fun createViewModel(): KanbanViewModel =
|
||||
KanbanViewModel(
|
||||
repository = mockRepository,
|
||||
preferences = preferences,
|
||||
eventsClientProvider = { mockEventsClient },
|
||||
endpointProvider = { "http://127.0.0.1:9119" },
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreateTaskSuccessWithWarning() =
|
||||
runTest(testDispatcher) {
|
||||
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||
val newTask = KanbanTask(id = "t_100", title = "New task", status = "todo")
|
||||
|
||||
coEvery { mockRepository.getBoard("dev") } returns
|
||||
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||
NetworkResult.Success(CreateTaskResponse(task = newTask, warning = "Dispatcher not running"))
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.selectBoard(board)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.createTask("New task", "Description", "todo")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
mockRepository.createTask(
|
||||
"dev",
|
||||
match { it.title == "New task" && it.body == "Description" },
|
||||
)
|
||||
}
|
||||
assertTrue(
|
||||
vm.uiState.value.toastMessage
|
||||
?.contains("Dispatcher not running") == true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreateTaskFailurePreservesStateAndShowsToast() =
|
||||
runTest(testDispatcher) {
|
||||
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||
coEvery { mockRepository.getBoard("dev") } returns
|
||||
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||
NetworkResult.Failure(NetworkError.Http(400, "Validation failed"))
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.selectBoard(board)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.createTask("Bad task", null, "todo")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertTrue(
|
||||
vm.uiState.value.toastMessage
|
||||
?.contains("Validation failed") == true,
|
||||
)
|
||||
assertFalse(vm.uiState.value.isCreatingTask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMoveTaskUpdatesBothTasksAndColumnsOptimistically() =
|
||||
runTest(testDispatcher) {
|
||||
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
|
||||
val colTodo = KanbanColumn("todo", listOf(task))
|
||||
val colReady = KanbanColumn("ready", emptyList())
|
||||
|
||||
val deferred = CompletableDeferred<NetworkResult<UpdateTaskResponse>>()
|
||||
|
||||
coEvery { mockRepository.getBoard("dev") } returns
|
||||
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo, colReady)))
|
||||
coEvery { mockRepository.updateTask("t_1", "dev", any()) } coAnswers { deferred.await() }
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.selectBoard(board)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// Initiate move
|
||||
vm.moveTask(task, KanbanTaskAction.READY)
|
||||
|
||||
// Optimistic check: both tasks AND columns must reflect "ready"!
|
||||
assertEquals(
|
||||
"ready",
|
||||
vm.uiState.value.tasks
|
||||
.first { it.id == "t_1" }
|
||||
.status,
|
||||
)
|
||||
assertTrue(
|
||||
vm.uiState.value.columns
|
||||
.first { it.name == "todo" }
|
||||
.tasks
|
||||
.none { it.id == "t_1" },
|
||||
)
|
||||
assertTrue(
|
||||
vm.uiState.value.columns
|
||||
.first { it.name == "ready" }
|
||||
.tasks
|
||||
.any { it.id == "t_1" },
|
||||
)
|
||||
assertTrue(
|
||||
vm.uiState.value.operatingTaskIds
|
||||
.contains("t_1"),
|
||||
)
|
||||
|
||||
// Complete network call
|
||||
deferred.complete(NetworkResult.Success(UpdateTaskResponse(task = task.copy(status = "ready"))))
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertFalse(
|
||||
vm.uiState.value.operatingTaskIds
|
||||
.contains("t_1"),
|
||||
)
|
||||
assertEquals(
|
||||
"ready",
|
||||
vm.uiState.value.tasks
|
||||
.first { it.id == "t_1" }
|
||||
.status,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMoveTaskFailureRevertsTasksAndColumns() =
|
||||
runTest(testDispatcher) {
|
||||
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
|
||||
val colTodo = KanbanColumn("todo", listOf(task))
|
||||
val colReady = KanbanColumn("ready", emptyList())
|
||||
|
||||
coEvery { mockRepository.getBoard("dev") } returns
|
||||
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo, colReady)))
|
||||
coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns
|
||||
NetworkResult.Failure(NetworkError.Http(409, "Parent blocked"))
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.selectBoard(board)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.moveTask(task, KanbanTaskAction.READY)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// Reverted back to "todo" in both tasks and columns!
|
||||
assertEquals(
|
||||
"todo",
|
||||
vm.uiState.value.tasks
|
||||
.first { it.id == "t_1" }
|
||||
.status,
|
||||
)
|
||||
assertTrue(
|
||||
vm.uiState.value.columns
|
||||
.first { it.name == "todo" }
|
||||
.tasks
|
||||
.any { it.id == "t_1" },
|
||||
)
|
||||
assertTrue(
|
||||
vm.uiState.value.columns
|
||||
.first { it.name == "ready" }
|
||||
.tasks
|
||||
.none { it.id == "t_1" },
|
||||
)
|
||||
assertTrue(
|
||||
vm.uiState.value.toastMessage
|
||||
?.contains("Parent blocked") == true,
|
||||
)
|
||||
assertFalse(
|
||||
vm.uiState.value.operatingTaskIds
|
||||
.contains("t_1"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDuplicateConcurrentMoveIgnored() =
|
||||
runTest(testDispatcher) {
|
||||
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
|
||||
val colTodo = KanbanColumn("todo", listOf(task))
|
||||
|
||||
val deferred = CompletableDeferred<NetworkResult<UpdateTaskResponse>>()
|
||||
coEvery { mockRepository.getBoard("dev") } returns
|
||||
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo)))
|
||||
coEvery { mockRepository.updateTask("t_1", "dev", any()) } coAnswers { deferred.await() }
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.selectBoard(board)
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
// First tap
|
||||
vm.moveTask(task, KanbanTaskAction.READY)
|
||||
// Second duplicate tap while in-flight
|
||||
vm.moveTask(task, KanbanTaskAction.ARCHIVE)
|
||||
|
||||
testDispatcher.scheduler.runCurrent()
|
||||
|
||||
// Only 1 updateTask call initiated
|
||||
coVerify(exactly = 1) { mockRepository.updateTask("t_1", "dev", any()) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user