feat(kanban): enforce board scoping and local selection in KanbanViewModel

This commit is contained in:
M57 committed 2026-09-14 22:23:01 +03:00
1 parent a486191d9d
commit c57e056d71
6 files changed
+900 -112

No files matched your search

@@ -0,0 +1,110 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
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.KanbanApiService
import com.m57.hermescontrol.data.remote.NetworkResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanRepositoryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val mockApi = mockk<KanbanApiService>(relaxed = true)
private lateinit var repository: KanbanRepository
@Before
fun setUp() {
repository = KanbanRepositoryImpl(apiProvider = { mockApi }, ioDispatcher = testDispatcher)
}
@Test
fun testGetBoardsPassesIncludeArchived() =
runTest(testDispatcher) {
coEvery { mockApi.getBoards(true) } returns Response.success(KanbanBoardsResponse())
val result = repository.getBoards(includeArchived = true)
assertTrue(result is NetworkResult.Success)
coVerify { mockApi.getBoards(true) }
}
@Test
fun testGetBoardScopedPassesBoardAndTenant() =
runTest(testDispatcher) {
coEvery {
mockApi.getBoard(
board = "custom",
includeArchived = false,
tenant = "team-a",
)
} returns Response.success(KanbanBoardResponse())
val result = repository.getBoard(board = "custom", includeArchived = false, tenant = "team-a")
assertTrue(result is NetworkResult.Success)
coVerify { mockApi.getBoard(board = "custom", includeArchived = false, tenant = "team-a") }
}
@Test
fun testCreateTaskScopesToBoard() =
runTest(testDispatcher) {
val body = CreateTaskBody(title = "New Task")
coEvery { mockApi.createTask(board = "ops", body = body) } returns
Response.success(
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "New Task", status = "triage")),
)
val result = repository.createTask(board = "ops", body = body)
assertTrue(result is NetworkResult.Success)
assertEquals("t_1", (result as NetworkResult.Success).data.task?.id)
coVerify { mockApi.createTask(board = "ops", body = body) }
}
@Test
fun testUpdateTaskScopesToBoard() =
runTest(testDispatcher) {
val body = UpdateTaskBody(status = "ready")
coEvery { mockApi.updateTask(taskId = "t_1", board = "ops", body = body) } returns
Response.success(
UpdateTaskResponse(task = KanbanTask(id = "t_1", title = "New Task", status = "ready")),
)
val result = repository.updateTask(taskId = "t_1", board = "ops", body = body)
assertTrue(result is NetworkResult.Success)
assertEquals("ready", (result as NetworkResult.Success).data.task?.status)
coVerify { mockApi.updateTask(taskId = "t_1", board = "ops", body = body) }
}
@Test
fun testDeleteTaskScopesToBoard() =
runTest(testDispatcher) {
coEvery { mockApi.deleteTask(taskId = "t_1", board = "ops") } returns Response.success(Unit)
val result = repository.deleteTask(taskId = "t_1", board = "ops")
assertTrue(result is NetworkResult.Success)
coVerify { mockApi.deleteTask(taskId = "t_1", board = "ops") }
}
@Test
fun testHttpErrorWrapsFailure() =
runTest(testDispatcher) {
coEvery { mockApi.getBoard("missing", false, null) } returns
Response.error(404, "Not Found".toResponseBody())
val result = repository.getBoard("missing")
assertTrue(result is NetworkResult.Failure)
}
}
@@ -0,0 +1,244 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask
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.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanScopeTest {
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 },
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testBoardSwitchScopesCorrectlyForSameTaskId() =
runTest(testDispatcher) {
val boardA = KanbanBoard(id = "board_a", name = "Board A")
val boardB = KanbanBoard(id = "board_b", name = "Board B")
val taskA = KanbanTask(id = "t_1", title = "Task in Board A", status = "todo")
val taskB = KanbanTask(id = "t_1", title = "Task in Board B", status = "ready")
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
coEvery { mockRepository.getBoard("board_a") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskA)))))
coEvery { mockRepository.getBoard("board_b") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("ready", listOf(taskB)))))
val vm = createViewModel()
vm.loadBoards()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_a",
vm.uiState.value.selectedBoard
?.id,
)
assertEquals(
"Task in Board A",
vm.uiState.value.tasks
.first()
.title,
)
// Switch to board B
vm.selectBoard(boardB)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_b",
vm.uiState.value.selectedBoard
?.id,
)
assertEquals(
"Task in Board B",
vm.uiState.value.tasks
.first()
.title,
)
assertEquals(
"ready",
vm.uiState.value.tasks
.first()
.status,
)
}
@Test
fun testDelayedLateResponseDoesNotOverwriteNewerSelection() =
runTest(testDispatcher) {
val boardA = KanbanBoard(id = "board_a", name = "Board A")
val boardB = KanbanBoard(id = "board_b", name = "Board B")
val taskA = KanbanTask(id = "t_a", title = "Task A", status = "todo")
val taskB = KanbanTask(id = "t_b", title = "Task B", status = "todo")
val deferredB = CompletableDeferred<NetworkResult<KanbanBoardResponse>>()
coEvery { mockRepository.getBoard("board_a") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskA)))))
coEvery { mockRepository.getBoard("board_b") } coAnswers { deferredB.await() }
val vm = createViewModel()
// User selects board B (deferred response)
vm.selectBoard(boardB)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_b",
vm.uiState.value.selectedBoard
?.id,
)
// User quickly selects board A before B responds
vm.selectBoard(boardA)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_a",
vm.uiState.value.selectedBoard
?.id,
)
assertEquals(
"Task A",
vm.uiState.value.tasks
.first()
.title,
)
// Now B finally resolves late
deferredB.complete(
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskB))))),
)
testDispatcher.scheduler.advanceUntilIdle()
// State MUST remain Board A and Task A!
assertEquals(
"board_a",
vm.uiState.value.selectedBoard
?.id,
)
assertEquals(
"Task A",
vm.uiState.value.tasks
.first()
.title,
)
}
@Test
fun testBoardListRefreshPreservesUserSelection() =
runTest(testDispatcher) {
val boardA = KanbanBoard(id = "board_a", name = "Board A")
val boardB = KanbanBoard(id = "board_b", name = "Board B")
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
coEvery { mockRepository.getBoard("board_b") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
val vm = createViewModel()
vm.selectBoard(boardB)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_b",
vm.uiState.value.selectedBoard
?.id,
)
// Refresh boards — server says current is "board_a"
vm.loadBoards()
testDispatcher.scheduler.advanceUntilIdle()
// Local user selection "board_b" must be preserved!
assertEquals(
"board_b",
vm.uiState.value.selectedBoard
?.id,
)
}
@Test
fun testEmptyBoardListHandledGracefully() =
runTest(testDispatcher) {
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(KanbanBoardsResponse(boards = emptyList(), current = null))
val vm = createViewModel()
vm.loadBoards()
testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.selectedBoard)
assertTrue(
vm.uiState.value.tasks
.isEmpty(),
)
assertTrue(
vm.uiState.value.columns
.isEmpty(),
)
}
@Test
fun testZeroSwitchCallsDuringNavigation() =
runTest(testDispatcher) {
val boardA = KanbanBoard(id = "board_a", name = "Board A")
val boardB = KanbanBoard(id = "board_b", name = "Board B")
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
coEvery { mockRepository.getBoard(any()) } returns
NetworkResult.Success(KanbanBoardResponse())
val vm = createViewModel()
vm.loadBoards()
testDispatcher.scheduler.advanceUntilIdle()
vm.selectBoard(boardB)
testDispatcher.scheduler.advanceUntilIdle()
// Zero calls to switchBoard anywhere!
coVerify(exactly = 0) { mockRepository.updateBoard(any(), any()) }
}
}
@@ -5,8 +5,9 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.KanbanApiService
import com.m57.hermescontrol.data.ws.KanbanEvent
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
@@ -23,7 +24,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
@@ -38,7 +38,7 @@ import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true)
private val mockApi = mockk<KanbanApiService>(relaxed = true)
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private fun createViewModel(): KanbanViewModel {
@@ -60,23 +60,24 @@ class KanbanViewModelTest {
}
private fun stubBoard(tasks: List<KanbanTask> = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo"))) {
coEvery { mockApi.getKanbanBoards() } returns
coEvery { mockApi.getBoards(any()) } returns
Response.success(
KanbanBoardsResponse(
boards = listOf(KanbanBoard(id = "work", name = "Work")),
current = "work",
),
)
coEvery { mockApi.switchKanbanBoard("work") } returns Response.success(Unit)
coEvery { mockApi.getKanbanBoard() } returns
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = tasks))))
coEvery { mockApi.getBoard(board = "ops", any(), any()) } returns
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = emptyList()))))
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
every { ApiClient.kanbanApi } returns mockApi
stubBoard()
}
@@ -115,7 +116,7 @@ class KanbanViewModelTest {
val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>()
verify { mockEventsClient.connect(any(), any(), any(), capture(eventsSlot), any()) }
coEvery { mockApi.getKanbanBoard() } returns
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(
KanbanBoardResponse(
columns =
@@ -138,7 +139,7 @@ class KanbanViewModelTest {
testDispatcher.scheduler.advanceTimeBy(1)
settle()
assertEquals(2, vm.uiState.value.tasks.size)
coVerify(exactly = 2) { mockApi.getKanbanBoard() }
coVerify(atLeast = 2) { mockApi.getBoard(board = "work", any(), any()) }
}
@Test
@@ -146,9 +147,6 @@ class KanbanViewModelTest {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.switchKanbanBoard("ops") } returns Response.success(Unit)
coEvery { mockApi.getKanbanBoard() } returns
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = emptyList()))))
vm.selectBoard(KanbanBoard(id = "ops", name = "Ops"))
settle()
verify { mockEventsClient.connect(any(), eq("ops"), any(), any(), any()) }
@@ -219,7 +217,7 @@ class KanbanViewModelTest {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle()
assertEquals(
@@ -228,7 +226,7 @@ class KanbanViewModelTest {
.first { it.id == "t1" }
.status,
)
coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) }
coVerify { mockApi.updateTask("t1", "work", match { it.status == "ready" }) }
}
@Test
@@ -236,7 +234,7 @@ class KanbanViewModelTest {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody())
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.error(409, "".toResponseBody())
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle()
assertEquals(
@@ -256,7 +254,7 @@ class KanbanViewModelTest {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
vm.moveTask(
KanbanTask(id = "t1", title = "Task 1", status = "ready"),
KanbanTaskAction.COMPLETE,
@@ -264,9 +262,10 @@ class KanbanViewModelTest {
)
settle()
coVerify {
mockApi.updateKanbanTask(
mockApi.updateTask(
"t1",
mapOf("status" to "done", "result" to "Shipped it", "summary" to "Shipped it"),
"work",
match { it.status == "done" && it.summary == "Shipped it" && it.result == "Shipped it" },
)
}
}