From d693c421420bc7dbad32338f6a4f4eb39b55670a Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 20:34:55 -0400 Subject: [PATCH] feat: add card detail repository with session-aware operations --- AGENTS.md | 3 +- .../app/carddetail/CardDetailRepository.kt | 245 ++++++++++++ .../carddetail/CardDetailRepositoryTest.kt | 352 ++++++++++++++++++ 3 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepository.kt create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepositoryTest.kt diff --git a/AGENTS.md b/AGENTS.md index 59e9b30..e37d08e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,8 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - The modal dialog has "Add comment" as a title. - The modal dialog has an editable markdown-enabled text field for the comment. - The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively. -- Current status: full card detail is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras. +- Current status: full card detail UI is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras. +- Card detail data operations now include a dedicated `CardDetailRepository` with session-aware failure mapping (`Missing session. Please sign in again.` and typed session-expired failures for auth errors), field normalization for title/description/due date updates, activity listing capped to newest 10, and add-comment refresh behavior. **Settings view** - The view shows a list of settings that can be changed by the user. The following settings are available: diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepository.kt new file mode 100644 index 0000000..bf33fba --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepository.kt @@ -0,0 +1,245 @@ +package space.hackenslacker.kanbn4droid.app.carddetail + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.time.LocalDate +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 CardDetailRepository( + private val sessionStore: SessionStore, + private val apiKeyStore: ApiKeyStore, + private val apiClient: KanbnApiClient, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + + companion object { + const val MISSING_SESSION_MESSAGE = "Missing session. Please sign in again." + private const val SESSION_EXPIRED_MESSAGE = "Session expired. Please sign in again." + } + + sealed interface Result { + data class Success(val value: T) : Result + + sealed interface Failure : Result { + val message: String + + data class Generic(override val message: String) : Failure + data class SessionExpired(override val message: String = SESSION_EXPIRED_MESSAGE) : Failure + } + } + + suspend fun updateTitle(cardId: String, title: String): Result { + val normalizedCardId = cardId.trim() + if (normalizedCardId.isBlank()) { + return Result.Failure.Generic("Card id is required") + } + + val normalizedTitle = title.trim() + if (normalizedTitle.isBlank()) { + return Result.Failure.Generic("Card title is required") + } + + val session = when (val sessionResult = session()) { + is Result.Success -> sessionResult.value + is Result.Failure -> return sessionResult + } + + val detail = when ( + val detailResult = apiClient.getCardDetail( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = normalizedCardId, + ) + ) { + is BoardsApiResult.Success -> detailResult.value + is BoardsApiResult.Failure -> return mapFailure(detailResult.message) + } + + return when ( + val updateResult = apiClient.updateCard( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = normalizedCardId, + title = normalizedTitle, + description = detail.description, + dueDate = detail.dueDate, + ) + ) { + is BoardsApiResult.Success -> Result.Success(Unit) + is BoardsApiResult.Failure -> mapFailure(updateResult.message) + } + } + + suspend fun updateDescription(cardId: String, description: String?): Result { + val normalizedCardId = cardId.trim() + if (normalizedCardId.isBlank()) { + return Result.Failure.Generic("Card id is required") + } + + val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() } + + val session = when (val sessionResult = session()) { + is Result.Success -> sessionResult.value + is Result.Failure -> return sessionResult + } + + val detail = when ( + val detailResult = apiClient.getCardDetail( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = normalizedCardId, + ) + ) { + is BoardsApiResult.Success -> detailResult.value + is BoardsApiResult.Failure -> return mapFailure(detailResult.message) + } + + return when ( + val updateResult = apiClient.updateCard( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = normalizedCardId, + title = detail.title, + description = normalizedDescription ?: "", + dueDate = detail.dueDate, + ) + ) { + is BoardsApiResult.Success -> Result.Success(Unit) + is BoardsApiResult.Failure -> mapFailure(updateResult.message) + } + } + + suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): Result { + val normalizedCardId = cardId.trim() + if (normalizedCardId.isBlank()) { + return Result.Failure.Generic("Card id is required") + } + + val session = when (val sessionResult = session()) { + is Result.Success -> sessionResult.value + is Result.Failure -> return sessionResult + } + + val detail = when ( + val detailResult = apiClient.getCardDetail( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = normalizedCardId, + ) + ) { + is BoardsApiResult.Success -> detailResult.value + is BoardsApiResult.Failure -> return mapFailure(detailResult.message) + } + + return when ( + val updateResult = apiClient.updateCard( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = normalizedCardId, + title = detail.title, + description = detail.description, + dueDate = dueDate, + ) + ) { + is BoardsApiResult.Success -> Result.Success(Unit) + is BoardsApiResult.Failure -> mapFailure(updateResult.message) + } + } + + suspend fun listActivities(cardId: String): Result> { + val normalizedCardId = cardId.trim() + if (normalizedCardId.isBlank()) { + return Result.Failure.Generic("Card id is required") + } + + val session = when (val sessionResult = session()) { + is Result.Success -> sessionResult.value + is Result.Failure -> return sessionResult + } + + return when ( + val activitiesResult = apiClient.listCardActivities( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = normalizedCardId, + ) + ) { + is BoardsApiResult.Success -> Result.Success( + activitiesResult.value + .sortedByDescending { it.createdAtEpochMillis } + .take(10), + ) + + is BoardsApiResult.Failure -> mapFailure(activitiesResult.message) + } + } + + suspend fun addComment(cardId: String, comment: String): Result> { + val normalizedCardId = cardId.trim() + if (normalizedCardId.isBlank()) { + return Result.Failure.Generic("Card id is required") + } + + val normalizedComment = comment.trim() + if (normalizedComment.isBlank()) { + return Result.Failure.Generic("Comment is required") + } + + val session = when (val sessionResult = session()) { + is Result.Success -> sessionResult.value + is Result.Failure -> return sessionResult + } + + when ( + val addCommentResult = apiClient.addCardComment( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + cardId = normalizedCardId, + comment = normalizedComment, + ) + ) { + is BoardsApiResult.Success -> Unit + is BoardsApiResult.Failure -> return mapFailure(addCommentResult.message) + } + + return listActivities(normalizedCardId) + } + + private suspend fun session(): Result { + val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } + ?: return Result.Failure.SessionExpired(MISSING_SESSION_MESSAGE) + val apiKey = withContext(ioDispatcher) { + apiKeyStore.getApiKey(baseUrl) + }.getOrNull()?.takeIf { it.isNotBlank() } + ?: return Result.Failure.SessionExpired(MISSING_SESSION_MESSAGE) + + return Result.Success(SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey)) + } + + private fun mapFailure(message: String): Result.Failure { + val normalizedMessage = message.trim().ifBlank { "Unknown error" } + return if (isAuthFailure(normalizedMessage)) { + Result.Failure.SessionExpired() + } else { + Result.Failure.Generic(normalizedMessage) + } + } + + private fun isAuthFailure(message: String): Boolean { + val lower = message.lowercase() + return lower.contains("authentication failed") || + lower.contains("server error: 401") || + lower.contains("server error: 403") || + lower.contains(" 401") || + lower.contains(" 403") + } + + private data class SessionSnapshot( + val baseUrl: String, + val apiKey: String, + ) +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepositoryTest.kt new file mode 100644 index 0000000..86fe78f --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailRepositoryTest.kt @@ -0,0 +1,352 @@ +package space.hackenslacker.kanbn4droid.app.carddetail + +import java.time.LocalDate +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.LabelDetail +import space.hackenslacker.kanbn4droid.app.auth.SessionStore +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail +import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef +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 CardDetailRepositoryTest { + + @Test + fun missingSession_returnsSessionExpiredFailure() = runTest { + val repository = createRepository( + sessionStore = InMemorySessionStore(baseUrl = null), + ) + + val result = repository.listActivities("card-1") + + assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired) + assertEquals( + CardDetailRepository.MISSING_SESSION_MESSAGE, + (result as CardDetailRepository.Result.Failure.SessionExpired).message, + ) + } + + @Test + fun updateDescription_blankMapsToNull_andPreservesFailureMessage() = runTest { + val apiClient = FakeCardDetailApiClient().apply { + updateCardResult = BoardsApiResult.Failure("Card is archived") + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.updateDescription(cardId = "card-1", description = " ") + + assertEquals(null, apiClient.lastUpdatedDescriptionNormalized) + assertTrue(result is CardDetailRepository.Result.Failure.Generic) + assertEquals("Card is archived", (result as CardDetailRepository.Result.Failure.Generic).message) + } + + @Test + fun updateTitle_authFailureMapsToSessionExpired() = runTest { + val apiClient = FakeCardDetailApiClient().apply { + updateCardResult = BoardsApiResult.Failure("Server error: 401") + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.updateTitle(cardId = "card-1", title = " Updated title ") + + assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired) + } + + @Test + fun listActivities_returnsNewestFirstTopTen() = runTest { + val apiClient = FakeCardDetailApiClient().apply { + listActivitiesResult = BoardsApiResult.Success((1..12).map { index -> + CardActivity( + id = "a-$index", + type = "comment", + text = "Activity $index", + createdAtEpochMillis = index.toLong(), + ) + }) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.listActivities("card-1") + + assertTrue(result is CardDetailRepository.Result.Success) + val activities = (result as CardDetailRepository.Result.Success>).value + assertEquals(10, activities.size) + assertEquals("a-12", activities[0].id) + assertEquals("a-3", activities[9].id) + } + + @Test + fun listActivities_authFailureMapsToSessionExpired() = runTest { + val apiClient = FakeCardDetailApiClient().apply { + listActivitiesResult = BoardsApiResult.Failure("Server error: 403") + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.listActivities("card-1") + + assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired) + } + + @Test + fun addComment_success_refreshesActivities() = runTest { + val apiClient = FakeCardDetailApiClient().apply { + addCommentResult = BoardsApiResult.Success(Unit) + listActivitiesResult = BoardsApiResult.Success( + listOf( + CardActivity( + id = "a-1", + type = "comment", + text = "hello", + createdAtEpochMillis = 1L, + ), + ), + ) + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.addComment(cardId = "card-1", comment = " hello ") + + assertTrue(result is CardDetailRepository.Result.Success) + assertEquals(1, apiClient.addCommentCalls) + assertEquals(1, apiClient.listActivitiesCalls) + assertEquals("hello", apiClient.lastComment) + } + + @Test + fun addComment_authFailureMapsToSessionExpired() = runTest { + val apiClient = FakeCardDetailApiClient().apply { + addCommentResult = BoardsApiResult.Failure("Authentication failed. Check your API key.") + } + val repository = createRepository(apiClient = apiClient) + + val result = repository.addComment(cardId = "card-1", comment = "A") + + assertTrue(result is CardDetailRepository.Result.Failure.SessionExpired) + } + + @Test + fun updateDueDate_dateOnlyInputNormalizesToUtcMidnightPayloadContract() = runTest { + val apiClient = FakeCardDetailApiClient() + val repository = createRepository(apiClient = apiClient) + + val result = repository.updateDueDate(cardId = "card-1", dueDate = LocalDate.parse("2026-03-16")) + + assertTrue(result is CardDetailRepository.Result.Success) + assertEquals(LocalDate.of(2026, 3, 16), apiClient.lastUpdatedDueDate) + } + + private fun createRepository( + sessionStore: InMemorySessionStore = InMemorySessionStore(baseUrl = "https://kan.bn/"), + apiKeyStore: InMemoryApiKeyStore = InMemoryApiKeyStore("api-key"), + apiClient: FakeCardDetailApiClient = FakeCardDetailApiClient(), + ): CardDetailRepository { + return CardDetailRepository( + 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 FakeCardDetailApiClient : KanbnApiClient { + var cardDetailResult: BoardsApiResult = BoardsApiResult.Success( + CardDetail( + id = "card-1", + title = "Current title", + description = "Current description", + dueDate = LocalDate.of(2026, 3, 1), + listPublicId = "list-1", + index = 0, + tags = emptyList(), + ), + ) + var updateCardResult: BoardsApiResult = BoardsApiResult.Success(Unit) + var listActivitiesResult: BoardsApiResult> = BoardsApiResult.Success(emptyList()) + var addCommentResult: BoardsApiResult = BoardsApiResult.Success(Unit) + + var lastUpdatedTitle: String? = null + var lastUpdatedDescription: String? = null + var lastUpdatedDescriptionNormalized: String? = null + var lastUpdatedDueDate: LocalDate? = null + var addCommentCalls: Int = 0 + var listActivitiesCalls: Int = 0 + var lastComment: String? = null + + override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success + + override suspend fun getCardDetail(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return cardDetailResult + } + + override suspend fun updateCard( + baseUrl: String, + apiKey: String, + cardId: String, + title: String, + description: String, + dueDate: LocalDate?, + ): BoardsApiResult { + lastUpdatedTitle = title + lastUpdatedDescription = description + lastUpdatedDescriptionNormalized = description.takeIf { it.isNotBlank() } + lastUpdatedDueDate = dueDate + return updateCardResult + } + + override suspend fun listCardActivities( + baseUrl: String, + apiKey: String, + cardId: String, + ): BoardsApiResult> { + listActivitiesCalls += 1 + return listActivitiesResult + } + + override suspend fun addCardComment( + baseUrl: String, + apiKey: String, + cardId: String, + comment: String, + ): BoardsApiResult { + addCommentCalls += 1 + lastComment = comment + return addCommentResult + } + + override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult> { + return BoardsApiResult.Success(emptyList()) + } + + 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("board-1", name)) + } + + override suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Success(Unit) + } + + override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Success(BoardDetail(id = boardId, title = "Board", lists = emptyList())) + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return BoardsApiResult.Success(Unit) + } + + override suspend fun createList( + baseUrl: String, + apiKey: String, + boardPublicId: String, + title: String, + appendIndex: Int, + ): BoardsApiResult { + return BoardsApiResult.Success(CreatedEntityRef("list-1")) + } + + override suspend fun createCard( + baseUrl: String, + apiKey: String, + listPublicId: String, + title: String, + description: String?, + dueDate: LocalDate?, + tagPublicIds: List, + ): BoardsApiResult { + return BoardsApiResult.Success(CreatedEntityRef("card-1")) + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return BoardsApiResult.Success(Unit) + } + + override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Success(Unit) + } + + override suspend fun getLabelByPublicId( + baseUrl: String, + apiKey: String, + labelId: String, + ): BoardsApiResult { + return BoardsApiResult.Success(LabelDetail(labelId, "#000000")) + } + } +}