feat: implement board detail repository and mutation aggregation

This commit is contained in:
2026-03-16 00:32:55 -04:00
parent e7ad14902d
commit c56b9d042a
2 changed files with 628 additions and 0 deletions

View File

@@ -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<BoardDetail> {
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<Unit> {
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<String>, 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<String, String>()
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<String>): 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<String, String>()
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<String>): List<String> {
return cardIds.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
}
private fun aggregateBatchMutationResult(
normalizedCardIds: List<String>,
failuresByCardId: Map<String, String>,
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<SessionSnapshot> {
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<String> {
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,
)
}

View File

@@ -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<Unit> {
this.apiKey = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(apiKey)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
apiKey = null
return Result.success(Unit)
}
}
private class FakeBoardDetailApiClient : KanbnApiClient {
var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> =
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
var boardDetailResult: BoardsApiResult<BoardDetail> = BoardsApiResult.Success(sampleBoardDetail())
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var moveOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
var deleteOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
var listWorkspacesCalls: Int = 0
var lastBoardId: String? = null
var lastListId: String? = null
var lastListTitle: String? = null
var movedCardIds: MutableList<String> = mutableListOf()
var deletedCardIds: MutableList<String> = 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<List<WorkspaceSummary>> {
listWorkspacesCalls += 1
return workspacesResult
}
override suspend fun getBoardDetail(
baseUrl: String,
apiKey: String,
boardId: String,
): BoardsApiResult<BoardDetail> {
lastBoardId = boardId
return boardDetailResult
}
override suspend fun renameList(
baseUrl: String,
apiKey: String,
listId: String,
newTitle: String,
): BoardsApiResult<Unit> {
lastListId = listId
lastListTitle = newTitle
return renameListResult
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,
cardId: String,
targetListId: String,
): BoardsApiResult<Unit> {
movedCardIds += cardId
lastMoveTargetListId = targetListId
return moveOutcomes[cardId] ?: BoardsApiResult.Success(Unit)
}
override suspend fun deleteCard(
baseUrl: String,
apiKey: String,
cardId: String,
): BoardsApiResult<Unit> {
deletedCardIds += cardId
return deleteOutcomes[cardId] ?: BoardsApiResult.Success(Unit)
}
override suspend fun listBoards(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardSummary>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun listBoardTemplates(
baseUrl: String,
apiKey: String,
workspaceId: String,
): BoardsApiResult<List<BoardTemplate>> {
return BoardsApiResult.Success(emptyList())
}
override suspend fun createBoard(
baseUrl: String,
apiKey: String,
workspaceId: String,
name: String,
templateId: String?,
): BoardsApiResult<BoardSummary> {
return BoardsApiResult.Success(BoardSummary("new", name))
}
override suspend fun deleteBoard(
baseUrl: String,
apiKey: String,
boardId: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Success(Unit)
}
}
private companion object {
fun sampleBoardDetail(): BoardDetail {
return BoardDetail(id = "board-1", title = "Board", lists = emptyList())
}
}
}