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,65 @@
package com.m57.hermescontrol.data.local
/**
* Local preferences for Kanban board viewing:
* selected board slug, view mode (list vs board), archive visibility, and group running.
*/
interface KanbanPreferencesStore {
fun getSelectedBoard(endpoint: String): String?
fun setSelectedBoard(
endpoint: String,
slug: String,
)
fun getViewMode(): String
fun setViewMode(mode: String)
fun getIncludeArchived(): Boolean
fun setIncludeArchived(include: Boolean)
fun getGroupRunning(): Boolean
fun setGroupRunning(group: Boolean)
companion object {
const val MODE_LIST = "list"
const val MODE_BOARD = "board"
}
}
class InMemoryKanbanPreferencesStore : KanbanPreferencesStore {
private val selectedBoards = mutableMapOf<String, String>()
private var viewMode: String = KanbanPreferencesStore.MODE_LIST
private var includeArchived: Boolean = false
private var groupRunning: Boolean = false
override fun getSelectedBoard(endpoint: String): String? = selectedBoards[endpoint]
override fun setSelectedBoard(
endpoint: String,
slug: String,
) {
selectedBoards[endpoint] = slug
}
override fun getViewMode(): String = viewMode
override fun setViewMode(mode: String) {
viewMode = mode
}
override fun getIncludeArchived(): Boolean = includeArchived
override fun setIncludeArchived(include: Boolean) {
includeArchived = include
}
override fun getGroupRunning(): Boolean = groupRunning
override fun setGroupRunning(group: Boolean) {
groupRunning = group
}
}
@@ -0,0 +1,366 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BulkTasksBody
import com.m57.hermescontrol.data.model.BulkTasksResponse
import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateBoardResponse
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReassignTaskResponse
import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.RenameBoardResponse
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.KanbanApiService
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MultipartBody
import okhttp3.ResponseBody
interface KanbanRepository {
suspend fun getBoards(includeArchived: Boolean = false): NetworkResult<KanbanBoardsResponse>
suspend fun getBoard(
board: String,
includeArchived: Boolean = false,
tenant: String? = null,
): NetworkResult<KanbanBoardResponse>
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
suspend fun updateBoard(
slug: String,
body: RenameBoardBody,
): NetworkResult<RenameBoardResponse>
suspend fun deleteBoard(
slug: String,
delete: Boolean = false,
): NetworkResult<DeleteBoardResponse>
suspend fun getTask(
taskId: String,
board: String?,
): NetworkResult<KanbanTaskDetailResponse>
suspend fun createTask(
board: String?,
body: CreateTaskBody,
): NetworkResult<CreateTaskResponse>
suspend fun updateTask(
taskId: String,
board: String?,
body: UpdateTaskBody,
): NetworkResult<UpdateTaskResponse>
suspend fun deleteTask(
taskId: String,
board: String?,
): NetworkResult<Unit>
suspend fun bulkTasks(
board: String?,
body: BulkTasksBody,
): NetworkResult<BulkTasksResponse>
suspend fun addComment(
taskId: String,
board: String?,
body: String,
): NetworkResult<Unit>
suspend fun reassignTask(
taskId: String,
board: String?,
profile: String?,
reclaimFirst: Boolean = true,
): NetworkResult<ReassignTaskResponse>
suspend fun reclaimTask(
taskId: String,
board: String?,
reason: String? = null,
): NetworkResult<ReclaimTaskResponse>
suspend fun getTaskLog(
taskId: String,
board: String?,
tail: Int = 16384,
): NetworkResult<WorkerLog>
suspend fun estimateTask(
taskId: String,
board: String?,
): NetworkResult<TaskEstimate>
suspend fun estimateNew(
title: String,
body: String?,
): NetworkResult<TaskEstimate>
suspend fun uploadAttachment(
taskId: String,
board: String?,
file: MultipartBody.Part,
): NetworkResult<AttachmentUploadResponse>
suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
): NetworkResult<ResponseBody>
suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse>
suspend fun updateProfileDescription(
name: String,
description: String,
): NetworkResult<Unit>
suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse>
suspend fun getOrchestration(): NetworkResult<OrchestrationSettings>
suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings>
suspend fun getProjects(): NetworkResult<KanbanProjectsResponse>
suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult>
}
class KanbanRepositoryImpl(
private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi },
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : KanbanRepository {
override suspend fun getBoards(includeArchived: Boolean): NetworkResult<KanbanBoardsResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getBoards(includeArchived) }
}
override suspend fun getBoard(
board: String,
includeArchived: Boolean,
tenant: String?,
): NetworkResult<KanbanBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) }
}
override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createBoard(body) }
}
override suspend fun updateBoard(
slug: String,
body: RenameBoardBody,
): NetworkResult<RenameBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateBoard(slug, body) }
}
override suspend fun deleteBoard(
slug: String,
delete: Boolean,
): NetworkResult<DeleteBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().deleteBoard(slug, delete) }
}
override suspend fun getTask(
taskId: String,
board: String?,
): NetworkResult<KanbanTaskDetailResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
}
override suspend fun createTask(
board: String?,
body: CreateTaskBody,
): NetworkResult<CreateTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createTask(board = board, body = body) }
}
override suspend fun updateTask(
taskId: String,
board: String?,
body: UpdateTaskBody,
): NetworkResult<UpdateTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) }
}
override suspend fun deleteTask(
taskId: String,
board: String?,
): NetworkResult<Unit> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) }
}
override suspend fun bulkTasks(
board: String?,
body: BulkTasksBody,
): NetworkResult<BulkTasksResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().bulkTasks(board = board, body = body) }
}
override suspend fun addComment(
taskId: String,
board: String?,
body: String,
): NetworkResult<Unit> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().addComment(
taskId = taskId,
board = board,
body = mapOf("body" to body, "author" to "mobile"),
)
}
}
override suspend fun reassignTask(
taskId: String,
board: String?,
profile: String?,
reclaimFirst: Boolean,
): NetworkResult<ReassignTaskResponse> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().reassignTask(
taskId = taskId,
board = board,
body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst),
)
}
}
override suspend fun reclaimTask(
taskId: String,
board: String?,
reason: String?,
): NetworkResult<ReclaimTaskResponse> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().reclaimTask(
taskId = taskId,
board = board,
body = ReclaimTaskBody(reason = reason),
)
}
}
override suspend fun getTaskLog(
taskId: String,
board: String?,
tail: Int,
): NetworkResult<WorkerLog> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getTaskLog(taskId = taskId, tail = tail, board = board) }
}
override suspend fun estimateTask(
taskId: String,
board: String?,
): NetworkResult<TaskEstimate> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().estimateTask(taskId = taskId, board = board) }
}
override suspend fun estimateNew(
title: String,
body: String?,
): NetworkResult<TaskEstimate> =
withContext(ioDispatcher) {
val payload =
buildMap {
put("title", title)
if (!body.isNullOrBlank()) put("body", body)
}
safeApiCall { apiProvider().estimateNew(payload) }
}
override suspend fun uploadAttachment(
taskId: String,
board: String?,
file: MultipartBody.Part,
): NetworkResult<AttachmentUploadResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
}
override suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
): NetworkResult<ResponseBody> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
}
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getProfiles() }
}
override suspend fun updateProfileDescription(
name: String,
description: String,
): NetworkResult<Unit> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().updateProfileDescription(
name = name,
body = mapOf("description" to description),
)
}
}
override suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().autoDescribeProfile(name) }
}
override suspend fun getOrchestration(): NetworkResult<OrchestrationSettings> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getOrchestration() }
}
override suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateOrchestration(body) }
}
override suspend fun getProjects(): NetworkResult<KanbanProjectsResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getProjects() }
}
override suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().nudgeDispatcher(board) }
}
}
@@ -2,17 +2,20 @@ package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.local.KanbanPreferencesStore
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,7 +23,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class KanbanUiState(
val isLoading: Boolean = false,
@@ -113,7 +115,12 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
}
class KanbanViewModel(
private val repository: KanbanRepository = KanbanRepositoryImpl(),
private val preferences: KanbanPreferencesStore = InMemoryKanbanPreferencesStore(),
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
private val endpointProvider: () -> String = {
runCatching { AuthManager.endpointForBuild().baseUrl.toString() }.getOrDefault("default")
},
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState())
@@ -122,58 +129,50 @@ class KanbanViewModel(
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var reloadJob: Job? = null
private var currentLoadGen: Int = 0
fun loadBoards() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val boards = data.boards.orEmpty()
_uiState.update { it.copy(isLoading = false, boards = boards) }
val currentSlug = data.current
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
if (currentBoard != null) {
selectBoard(currentBoard)
val endpoint = endpointProvider()
val savedSlug = preferences.getSelectedBoard(endpoint)
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
when (val result = repository.getBoards()) {
is NetworkResult.Success -> {
val boards = result.data.boards
_uiState.update { it.copy(isLoading = false, boards = boards) }
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.firstOrNull()
if (targetBoard != null) {
selectBoard(targetBoard)
} else {
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
}
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: $errorMsg",
)
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
}
}
},
)
}
}
}
fun selectBoard(board: KanbanBoard) {
val current = _uiState.value.selectedBoard
if (current?.id == board.id) {
// Already-active board: reload its data and (re)connect the events
// stream WITHOUT the /switch round-trip — refresh and createTask
// re-select the same board and must not POST a redundant switch.
viewModelScope.launch { loadBoardIntoState(board) }
connectEvents(board)
return
}
val endpoint = endpointProvider()
preferences.setSelectedBoard(endpoint, board.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
viewModelScope.launch {
val switchResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.switchKanbanBoard(board.id) }
}
if (switchResult is NetworkResult.Failure) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to switch Kanban board: ${switchResult.error.message}",
)
}
return@launch
}
loadBoardIntoState(board)
loadBoardIntoState(board, gen)
connectEvents(board)
}
}
@@ -186,22 +185,18 @@ class KanbanViewModel(
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.createKanbanTask(
board = board.id,
task =
com.m57.hermescontrol.data.model.CreateTaskBody(
title = title,
body = description,
),
)
}
}
repository.createTask(
board = board.id,
body =
CreateTaskBody(
title = title,
body = description,
),
)
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task created successfully") }
selectBoard(board)
reloadBoardSilently()
}
is NetworkResult.Failure -> {
@@ -216,6 +211,7 @@ class KanbanViewModel(
action: KanbanTaskAction,
summary: String? = null,
) {
val board = _uiState.value.selectedBoard ?: return
val originalStatus = task.status
// Optimistically update, desktop-style
_uiState.update { state ->
@@ -229,17 +225,12 @@ class KanbanViewModel(
viewModelScope.launch {
val body =
buildMap<String, String?> {
put("status", action.targetStatus)
if (action.needsSummary && !summary.isNullOrBlank()) {
put("result", summary)
put("summary", summary)
}
}
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) }
}
UpdateTaskBody(
status = action.targetStatus,
summary = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
)
val result = repository.updateTask(task.id, board = board.id, body = body)
if (result is NetworkResult.Failure) {
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
}
@@ -300,47 +291,60 @@ class KanbanViewModel(
/** Re-fetch the current board without touching the loading spinner. */
private fun reloadBoardSilently() {
val board = _uiState.value.selectedBoard ?: return
val gen = currentLoadGen
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
val result = repository.getBoard(board = board.id)
if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) {
val body = result.data
_uiState.update {
it.copy(
columns = body.columns,
tasks = body.columns.flatMap { it.tasks },
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
columns = body.columns,
tasks = body.columns.flatMap { col -> col.tasks },
)
}
}
}
}
}
private suspend fun loadBoardIntoState(board: KanbanBoard) {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
private suspend fun loadBoardIntoState(
board: KanbanBoard,
gen: Int = currentLoadGen,
) {
val result = repository.getBoard(board = board.id)
if (gen != currentLoadGen) return
when (result) {
is NetworkResult.Success -> {
val body = result.data
val allTasks = body.columns.flatMap { it.tasks }
_uiState.update {
it.copy(
isLoading = false,
columns = body.columns,
tasks = allTasks,
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
isLoading = false,
columns = body.columns,
tasks = allTasks,
)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
}
}
}
}
@@ -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" },
)
}
}