feat(kanban): enforce board scoping and local selection in KanbanViewModel
This commit is contained in:
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" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user