feat: implement board detail repository and mutation aggregation
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user