diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt new file mode 100644 index 0000000..0e9dc6e --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepository.kt @@ -0,0 +1,197 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult + +class BoardDetailRepository( + private val sessionStore: SessionStore, + private val apiKeyStore: ApiKeyStore, + private val apiClient: KanbnApiClient, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + suspend fun getBoardDetail(boardId: String): BoardsApiResult { + val normalizedBoardId = boardId.trim() + if (normalizedBoardId.isBlank()) { + return BoardsApiResult.Failure("Board id is required") + } + + val session = when (val sessionResult = session()) { + is BoardsApiResult.Success -> sessionResult.value + is BoardsApiResult.Failure -> return sessionResult + } + + return apiClient.getBoardDetail( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + boardId = normalizedBoardId, + ) + } + + suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { + val normalizedListId = listId.trim() + if (normalizedListId.isBlank()) { + return BoardsApiResult.Failure("List id is required") + } + + val normalizedTitle = newTitle.trim() + if (normalizedTitle.isBlank()) { + return BoardsApiResult.Failure("List title is required") + } + + val session = when (val sessionResult = session()) { + is BoardsApiResult.Success -> sessionResult.value + is BoardsApiResult.Failure -> return sessionResult + } + + return apiClient.renameList( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + listId = normalizedListId, + newTitle = normalizedTitle, + ) + } + + suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { + val normalizedTargetListId = targetListId.trim() + if (normalizedTargetListId.isBlank()) { + return CardBatchMutationResult.Failure("Target list id is required") + } + + val normalizedCardIds = normalizeCardIds(cardIds) + if (normalizedCardIds.isEmpty()) { + return CardBatchMutationResult.Failure("At least one card id is required") + } + + val session = when (val sessionResult = session()) { + is BoardsApiResult.Success -> sessionResult.value + is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message) + } + + val failuresByCardId = linkedMapOf() + normalizedCardIds.forEach { cardId -> + when ( + val result = apiClient.moveCard( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = cardId, + targetListId = normalizedTargetListId, + ) + ) { + is BoardsApiResult.Success -> Unit + is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message + } + } + + return aggregateBatchMutationResult( + normalizedCardIds = normalizedCardIds, + failuresByCardId = failuresByCardId, + partialMessage = "Some cards could not be moved. Please try again.", + ) + } + + suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + val normalizedCardIds = normalizeCardIds(cardIds) + if (normalizedCardIds.isEmpty()) { + return CardBatchMutationResult.Failure("At least one card id is required") + } + + val session = when (val sessionResult = session()) { + is BoardsApiResult.Success -> sessionResult.value + is BoardsApiResult.Failure -> return CardBatchMutationResult.Failure(sessionResult.message) + } + + val failuresByCardId = linkedMapOf() + normalizedCardIds.forEach { cardId -> + when (val result = apiClient.deleteCard(session.baseUrl, session.apiKey, cardId)) { + is BoardsApiResult.Success -> Unit + is BoardsApiResult.Failure -> failuresByCardId[cardId] = result.message + } + } + + return aggregateBatchMutationResult( + normalizedCardIds = normalizedCardIds, + failuresByCardId = failuresByCardId, + partialMessage = "Some cards could not be deleted. Please try again.", + ) + } + + private fun normalizeCardIds(cardIds: Collection): List { + return cardIds.map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + } + + private fun aggregateBatchMutationResult( + normalizedCardIds: List, + failuresByCardId: Map, + partialMessage: String, + ): CardBatchMutationResult { + if (failuresByCardId.isEmpty()) { + return CardBatchMutationResult.Success + } + + if (failuresByCardId.size == normalizedCardIds.size) { + val firstFailureMessage = normalizedCardIds + .asSequence() + .mapNotNull { failuresByCardId[it] } + .firstOrNull() + ?.trim() + .orEmpty() + .ifBlank { "Unknown error" } + return CardBatchMutationResult.Failure(firstFailureMessage) + } + + return CardBatchMutationResult.PartialSuccess( + failedCardIds = failuresByCardId.keys.toSet(), + message = partialMessage, + ) + } + + private suspend fun session(): BoardsApiResult { + val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } + ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + val apiKey = withContext(ioDispatcher) { + apiKeyStore.getApiKey(baseUrl) + }.getOrNull()?.takeIf { it.isNotBlank() } + ?: return BoardsApiResult.Failure("Missing session. Please sign in again.") + + val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) { + is BoardsApiResult.Success -> workspaceResult.value + is BoardsApiResult.Failure -> return workspaceResult + } + + return BoardsApiResult.Success( + SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId), + ) + } + + private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult { + val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() } + if (storedWorkspaceId != null) { + return BoardsApiResult.Success(storedWorkspaceId) + } + + return when (val workspacesResult = apiClient.listWorkspaces(baseUrl, apiKey)) { + is BoardsApiResult.Success -> { + val first = workspacesResult.value.firstOrNull()?.id + ?: return BoardsApiResult.Failure("No workspaces available for this account.") + sessionStore.saveWorkspaceId(first) + BoardsApiResult.Success(first) + } + + is BoardsApiResult.Failure -> workspacesResult + } + } + + private data class SessionSnapshot( + val baseUrl: String, + val apiKey: String, + @Suppress("unused") + val workspaceId: String, + ) +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt new file mode 100644 index 0000000..27ec121 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailRepositoryTest.kt @@ -0,0 +1,431 @@ +package space.hackenslacker.kanbn4droid.app.boarddetail + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore +import space.hackenslacker.kanbn4droid.app.auth.AuthResult +import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient +import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boards.BoardSummary +import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult +import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary + +class BoardDetailRepositoryTest { + + @Test + fun getBoardDetailUsesStoredWorkspaceWhenPresent() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + boardDetailResult = BoardsApiResult.Success(sampleBoardDetail()) + } + val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-stored") + val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient) + + val result = repository.getBoardDetail("board-1") + + assertTrue(result is BoardsApiResult.Success<*>) + assertEquals(0, apiClient.listWorkspacesCalls) + assertEquals("board-1", apiClient.lastBoardId) + } + + @Test + fun getBoardDetailFetchesAndPersistsWorkspaceWhenMissing() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + boardDetailResult = BoardsApiResult.Success(sampleBoardDetail()) + } + val sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/") + val repository = createRepository(sessionStore = sessionStore, apiClient = apiClient) + + val result = repository.getBoardDetail("board-1") + + assertTrue(result is BoardsApiResult.Success<*>) + assertEquals(1, apiClient.listWorkspacesCalls) + assertEquals("ws-1", sessionStore.getWorkspaceId()) + assertEquals("board-1", apiClient.lastBoardId) + } + + @Test + fun getBoardDetailFailsWhenNoWorkspacesAvailable() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + workspacesResult = BoardsApiResult.Success(emptyList()) + } + val repository = createRepository( + sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/"), + apiClient = apiClient, + ) + + val result = repository.getBoardDetail("board-1") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("No workspaces available for this account.", (result as BoardsApiResult.Failure).message) + } + + @Test + fun getBoardDetailPropagatesApiFailureMessageUnchanged() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + boardDetailResult = BoardsApiResult.Failure("Server says no") + } + val repository = createRepository( + sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"), + apiClient = apiClient, + ) + + val result = repository.getBoardDetail("board-1") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("Server says no", (result as BoardsApiResult.Failure).message) + } + + @Test + fun renameListPropagatesApiFailureMessageUnchanged() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + renameListResult = BoardsApiResult.Failure("List cannot be renamed") + } + val repository = createRepository( + sessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"), + apiClient = apiClient, + ) + + val result = repository.renameList("list-1", "New title") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("List cannot be renamed", (result as BoardsApiResult.Failure).message) + assertEquals("list-1", apiClient.lastListId) + assertEquals("New title", apiClient.lastListTitle) + } + + @Test + fun getBoardDetailValidatesBoardId() = runTest { + val repository = createRepository() + + val result = repository.getBoardDetail(" ") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("Board id is required", (result as BoardsApiResult.Failure).message) + } + + @Test + fun renameListValidatesListId() = runTest { + val repository = createRepository() + + val result = repository.renameList(" ", "Some title") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("List id is required", (result as BoardsApiResult.Failure).message) + } + + @Test + fun renameListValidatesTitle() = runTest { + val repository = createRepository() + + val result = repository.renameList("list-1", " ") + + assertTrue(result is BoardsApiResult.Failure) + assertEquals("List title is required", (result as BoardsApiResult.Failure).message) + } + + @Test + fun moveCardsValidatesTargetListId() = runTest { + val repository = createRepository() + + val result = repository.moveCards(cardIds = listOf("card-1"), targetListId = " ") + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("Target list id is required", (result as CardBatchMutationResult.Failure).message) + } + + @Test + fun moveCardsValidatesCardIds() = runTest { + val repository = createRepository() + + val result = repository.moveCards(cardIds = listOf(" ", ""), targetListId = "list-2") + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("At least one card id is required", (result as CardBatchMutationResult.Failure).message) + } + + @Test + fun deleteCardsValidatesCardIds() = runTest { + val repository = createRepository() + + val result = repository.deleteCards(cardIds = listOf(" ", "")) + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("At least one card id is required", (result as CardBatchMutationResult.Failure).message) + } + + @Test + fun moveCardsReturnsSuccessWhenAllMutationsSucceed() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + moveOutcomes = mapOf( + "card-1" to BoardsApiResult.Success(Unit), + "card-2" to BoardsApiResult.Success(Unit), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.moveCards( + cardIds = listOf(" card-1 ", "", "card-1", "card-2"), + targetListId = " list-target ", + ) + + assertEquals(CardBatchMutationResult.Success, result) + assertEquals(listOf("card-1", "card-2"), apiClient.movedCardIds) + assertEquals("list-target", apiClient.lastMoveTargetListId) + } + + @Test + fun moveCardsReturnsPartialSuccessWhenSomeMutationsFail() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + moveOutcomes = mapOf( + "card-1" to BoardsApiResult.Success(Unit), + "card-2" to BoardsApiResult.Failure("Cannot move"), + "card-3" to BoardsApiResult.Success(Unit), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.moveCards( + cardIds = listOf("card-1", " card-2 ", "card-3", "card-2"), + targetListId = "list-target", + ) + + assertTrue(result is CardBatchMutationResult.PartialSuccess) + assertEquals(setOf("card-2"), (result as CardBatchMutationResult.PartialSuccess).failedCardIds) + assertEquals("Some cards could not be moved. Please try again.", result.message) + assertEquals(listOf("card-1", "card-2", "card-3"), apiClient.movedCardIds) + } + + @Test + fun moveCardsReturnsFailureWithFirstNormalizedMessageWhenAllFail() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + moveOutcomes = mapOf( + "card-2" to BoardsApiResult.Failure(" "), + "card-1" to BoardsApiResult.Failure("Second failure"), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.moveCards( + cardIds = listOf(" card-2 ", "card-1", "card-2"), + targetListId = "list-target", + ) + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("Unknown error", (result as CardBatchMutationResult.Failure).message) + assertEquals(listOf("card-2", "card-1"), apiClient.movedCardIds) + } + + @Test + fun deleteCardsReturnsSuccessWhenAllMutationsSucceed() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + deleteOutcomes = mapOf( + "card-1" to BoardsApiResult.Success(Unit), + "card-2" to BoardsApiResult.Success(Unit), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.deleteCards(cardIds = listOf("card-1", " card-2 ", "card-1")) + + assertEquals(CardBatchMutationResult.Success, result) + assertEquals(listOf("card-1", "card-2"), apiClient.deletedCardIds) + } + + @Test + fun deleteCardsReturnsPartialSuccessWhenSomeMutationsFail() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + deleteOutcomes = mapOf( + "card-1" to BoardsApiResult.Success(Unit), + "card-2" to BoardsApiResult.Failure("Cannot delete"), + "card-3" to BoardsApiResult.Success(Unit), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.deleteCards(cardIds = listOf("card-1", " card-2 ", "card-3", "card-2")) + + assertTrue(result is CardBatchMutationResult.PartialSuccess) + assertEquals(setOf("card-2"), (result as CardBatchMutationResult.PartialSuccess).failedCardIds) + assertEquals("Some cards could not be deleted. Please try again.", result.message) + assertEquals(listOf("card-1", "card-2", "card-3"), apiClient.deletedCardIds) + } + + @Test + fun deleteCardsReturnsFailureWithFirstNormalizedMessageWhenAllFail() = runTest { + val apiClient = FakeBoardDetailApiClient().apply { + deleteOutcomes = mapOf( + "card-2" to BoardsApiResult.Failure("Delete failed first"), + "card-1" to BoardsApiResult.Failure("Delete failed second"), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.deleteCards(cardIds = listOf(" card-2 ", "card-1", "card-2")) + + assertTrue(result is CardBatchMutationResult.Failure) + assertEquals("Delete failed first", (result as CardBatchMutationResult.Failure).message) + assertEquals(listOf("card-2", "card-1"), apiClient.deletedCardIds) + } + + private fun createRepository( + sessionStore: InMemorySessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/", workspaceId = "ws-1"), + apiClient: FakeBoardDetailApiClient = FakeBoardDetailApiClient(), + apiKeyStore: InMemoryApiKeyStore = InMemoryApiKeyStore("api-key"), + ): BoardDetailRepository { + return BoardDetailRepository( + sessionStore = sessionStore, + apiKeyStore = apiKeyStore, + apiClient = apiClient, + ) + } + + private class InMemorySessionStore( + private var baseUrl: String?, + private var workspaceId: String? = null, + ) : SessionStore { + override fun getBaseUrl(): String? = baseUrl + + override fun saveBaseUrl(url: String) { + baseUrl = url + } + + override fun getWorkspaceId(): String? = workspaceId + + override fun saveWorkspaceId(workspaceId: String) { + this.workspaceId = workspaceId + } + + override fun clearBaseUrl() { + baseUrl = null + } + + override fun clearWorkspaceId() { + workspaceId = null + } + } + + private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore { + override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result { + this.apiKey = apiKey + return Result.success(Unit) + } + + override suspend fun getApiKey(baseUrl: String): Result = Result.success(apiKey) + + override suspend fun invalidateApiKey(baseUrl: String): Result { + apiKey = null + return Result.success(Unit) + } + } + + private class FakeBoardDetailApiClient : KanbnApiClient { + var workspacesResult: BoardsApiResult> = + BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + var boardDetailResult: BoardsApiResult = BoardsApiResult.Success(sampleBoardDetail()) + var renameListResult: BoardsApiResult = BoardsApiResult.Success(Unit) + var moveOutcomes: Map> = emptyMap() + var deleteOutcomes: Map> = emptyMap() + + var listWorkspacesCalls: Int = 0 + var lastBoardId: String? = null + var lastListId: String? = null + var lastListTitle: String? = null + var movedCardIds: MutableList = mutableListOf() + var deletedCardIds: MutableList = mutableListOf() + var lastMoveTargetListId: String? = null + + override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success + + override suspend fun listWorkspaces( + baseUrl: String, + apiKey: String, + ): BoardsApiResult> { + listWorkspacesCalls += 1 + return workspacesResult + } + + override suspend fun getBoardDetail( + baseUrl: String, + apiKey: String, + boardId: String, + ): BoardsApiResult { + lastBoardId = boardId + return boardDetailResult + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + lastListId = listId + lastListTitle = newTitle + return renameListResult + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + movedCardIds += cardId + lastMoveTargetListId = targetListId + return moveOutcomes[cardId] ?: BoardsApiResult.Success(Unit) + } + + override suspend fun deleteCard( + baseUrl: String, + apiKey: String, + cardId: String, + ): BoardsApiResult { + deletedCardIds += cardId + return deleteOutcomes[cardId] ?: BoardsApiResult.Success(Unit) + } + + override suspend fun listBoards( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { + return BoardsApiResult.Success(emptyList()) + } + + override suspend fun listBoardTemplates( + baseUrl: String, + apiKey: String, + workspaceId: String, + ): BoardsApiResult> { + return BoardsApiResult.Success(emptyList()) + } + + override suspend fun createBoard( + baseUrl: String, + apiKey: String, + workspaceId: String, + name: String, + templateId: String?, + ): BoardsApiResult { + return BoardsApiResult.Success(BoardSummary("new", name)) + } + + override suspend fun deleteBoard( + baseUrl: String, + apiKey: String, + boardId: String, + ): BoardsApiResult { + return BoardsApiResult.Success(Unit) + } + } + + private companion object { + fun sampleBoardDetail(): BoardDetail { + return BoardDetail(id = "board-1", title = "Board", lists = emptyList()) + } + } +}