From c57e056d71049df4b619874ff5756a54a6d841af Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:23:01 +0300 Subject: [PATCH] feat(kanban): enforce board scoping and local selection in KanbanViewModel --- .../data/local/KanbanPreferencesStore.kt | 65 ++++ .../data/repository/KanbanRepository.kt | 366 ++++++++++++++++++ .../ui/kanban/KanbanViewModel.kt | 192 ++++----- .../data/repository/KanbanRepositoryTest.kt | 110 ++++++ .../ui/kanban/KanbanScopeTest.kt | 244 ++++++++++++ .../ui/kanban/KanbanViewModelTest.kt | 35 +- 6 files changed, 900 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt b/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt new file mode 100644 index 00000000..9f42707e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt @@ -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() + 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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt new file mode 100644 index 00000000..456bfd0c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt @@ -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 + + suspend fun getBoard( + board: String, + includeArchived: Boolean = false, + tenant: String? = null, + ): NetworkResult + + suspend fun createBoard(body: CreateBoardBody): NetworkResult + + suspend fun updateBoard( + slug: String, + body: RenameBoardBody, + ): NetworkResult + + suspend fun deleteBoard( + slug: String, + delete: Boolean = false, + ): NetworkResult + + suspend fun getTask( + taskId: String, + board: String?, + ): NetworkResult + + suspend fun createTask( + board: String?, + body: CreateTaskBody, + ): NetworkResult + + suspend fun updateTask( + taskId: String, + board: String?, + body: UpdateTaskBody, + ): NetworkResult + + suspend fun deleteTask( + taskId: String, + board: String?, + ): NetworkResult + + suspend fun bulkTasks( + board: String?, + body: BulkTasksBody, + ): NetworkResult + + suspend fun addComment( + taskId: String, + board: String?, + body: String, + ): NetworkResult + + suspend fun reassignTask( + taskId: String, + board: String?, + profile: String?, + reclaimFirst: Boolean = true, + ): NetworkResult + + suspend fun reclaimTask( + taskId: String, + board: String?, + reason: String? = null, + ): NetworkResult + + suspend fun getTaskLog( + taskId: String, + board: String?, + tail: Int = 16384, + ): NetworkResult + + suspend fun estimateTask( + taskId: String, + board: String?, + ): NetworkResult + + suspend fun estimateNew( + title: String, + body: String?, + ): NetworkResult + + suspend fun uploadAttachment( + taskId: String, + board: String?, + file: MultipartBody.Part, + ): NetworkResult + + suspend fun downloadAttachment( + attachmentId: Long, + board: String?, + ): NetworkResult + + suspend fun getProfiles(): NetworkResult + + suspend fun updateProfileDescription( + name: String, + description: String, + ): NetworkResult + + suspend fun autoDescribeProfile(name: String): NetworkResult + + suspend fun getOrchestration(): NetworkResult + + suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult + + suspend fun getProjects(): NetworkResult + + suspend fun nudgeDispatcher(board: String?): NetworkResult +} + +class KanbanRepositoryImpl( + private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi }, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : KanbanRepository { + override suspend fun getBoards(includeArchived: Boolean): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getBoards(includeArchived) } + } + + override suspend fun getBoard( + board: String, + includeArchived: Boolean, + tenant: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) } + } + + override suspend fun createBoard(body: CreateBoardBody): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().createBoard(body) } + } + + override suspend fun updateBoard( + slug: String, + body: RenameBoardBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().updateBoard(slug, body) } + } + + override suspend fun deleteBoard( + slug: String, + delete: Boolean, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().deleteBoard(slug, delete) } + } + + override suspend fun getTask( + taskId: String, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getTask(taskId = taskId, board = board) } + } + + override suspend fun createTask( + board: String?, + body: CreateTaskBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().createTask(board = board, body = body) } + } + + override suspend fun updateTask( + taskId: String, + board: String?, + body: UpdateTaskBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) } + } + + override suspend fun deleteTask( + taskId: String, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) } + } + + override suspend fun bulkTasks( + board: String?, + body: BulkTasksBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().bulkTasks(board = board, body = body) } + } + + override suspend fun addComment( + taskId: String, + board: String?, + body: String, + ): NetworkResult = + 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 = + 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 = + withContext(ioDispatcher) { + safeApiCall { + apiProvider().reclaimTask( + taskId = taskId, + board = board, + body = ReclaimTaskBody(reason = reason), + ) + } + } + + override suspend fun getTaskLog( + taskId: String, + board: String?, + tail: Int, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getTaskLog(taskId = taskId, tail = tail, board = board) } + } + + override suspend fun estimateTask( + taskId: String, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().estimateTask(taskId = taskId, board = board) } + } + + override suspend fun estimateNew( + title: String, + body: String?, + ): NetworkResult = + 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 = + withContext(ioDispatcher) { + safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) } + } + + override suspend fun downloadAttachment( + attachmentId: Long, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) } + } + + override suspend fun getProfiles(): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getProfiles() } + } + + override suspend fun updateProfileDescription( + name: String, + description: String, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { + apiProvider().updateProfileDescription( + name = name, + body = mapOf("description" to description), + ) + } + } + + override suspend fun autoDescribeProfile(name: String): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().autoDescribeProfile(name) } + } + + override suspend fun getOrchestration(): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getOrchestration() } + } + + override suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().updateOrchestration(body) } + } + + override suspend fun getProjects(): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getProjects() } + } + + override suspend fun nudgeDispatcher(board: String?): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().nudgeDispatcher(board) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 4bb7293d..974aa3e0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -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 = } 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 { - 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}", + ) + } } } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt new file mode 100644 index 00000000..393fffc2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt @@ -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(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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt new file mode 100644 index 00000000..36e16be9 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt @@ -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(relaxed = true) + private val mockEventsClient = mockk(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>() + + 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()) } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt index 5660cd3c..d60587ff 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt @@ -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(relaxed = true) + private val mockApi = mockk(relaxed = true) private val mockEventsClient = mockk(relaxed = true) private fun createViewModel(): KanbanViewModel { @@ -60,23 +60,24 @@ class KanbanViewModelTest { } private fun stubBoard(tasks: List = 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" }, ) } }