From d1628417089de9f37e69709d930a4353f0c0ce00 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Thu, 30 Apr 2026 13:59:28 -0400 Subject: [PATCH] fix: keep template boards out of boards list --- app/build.gradle.kts | 1 + .../kanbn4droid/app/auth/KanbnApiClient.kt | 16 + .../app/boards/BoardsRepository.kt | 21 +- .../HttpKanbnApiClientBoardsParsingTest.kt | 305 ++++++++++++++++++ .../app/boards/BoardsRepositoryTest.kt | 59 ++++ 5 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardsParsingTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f4ea127..2019b21 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) + testImplementation("org.json:json:20240303") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.contrib) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt index 9c38664..cb5fe5b 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -975,6 +975,9 @@ class HttpKanbnApiClient : KanbnApiClient { val boards = mutableListOf() for (index in 0 until array.length()) { val item = array.optJSONObject(index) ?: continue + if (isTemplateBoard(item)) { + continue + } val id = item.opt("id")?.toString().orEmpty().ifBlank { item.optString("publicId") .ifBlank { item.optString("public_id") } @@ -991,6 +994,19 @@ class HttpKanbnApiClient : KanbnApiClient { return boards } + private fun isTemplateBoard(item: JSONObject): Boolean { + val typeKeys = listOf("type", "boardType", "kind") + val hasTemplateType = typeKeys.any { key -> + item.optString(key) + .trim() + .equals("template", ignoreCase = true) + } + if (hasTemplateType) { + return true + } + return item.optBoolean("isTemplate", false) + } + private fun parseSingleBoard(body: String, fallbackName: String): BoardSummary { if (body.isBlank()) { return BoardSummary(id = "new", title = fallbackName) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt index 37bf305..47652b3 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepository.kt @@ -93,11 +93,30 @@ class BoardsRepository( is BoardsApiResult.Success -> sessionResult.value is BoardsApiResult.Failure -> return sessionResult } - return apiClient.listBoards( + val boardsResult = apiClient.listBoards( baseUrl = session.baseUrl, apiKey = session.apiKey, workspaceId = session.workspaceId, ) + if (boardsResult is BoardsApiResult.Failure) { + return boardsResult + } + + val boards = (boardsResult as BoardsApiResult.Success).value + val templatesResult = apiClient.listBoardTemplates( + baseUrl = session.baseUrl, + apiKey = session.apiKey, + workspaceId = session.workspaceId, + ) + + return when (templatesResult) { + is BoardsApiResult.Success -> { + val templateIds = templatesResult.value.map { it.id }.toSet() + BoardsApiResult.Success(boards.filterNot { it.id in templateIds }) + } + + is BoardsApiResult.Failure -> BoardsApiResult.Success(boards) + } } suspend fun listTemplates(): BoardsApiResult> { diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardsParsingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardsParsingTest.kt new file mode 100644 index 0000000..cdd3572 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardsParsingTest.kt @@ -0,0 +1,305 @@ +package space.hackenslacker.kanbn4droid.app.auth + +import java.io.BufferedInputStream +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import space.hackenslacker.kanbn4droid.app.boards.BoardSummary +import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult + +class HttpKanbnApiClientBoardsParsingTest { + + @Test + fun listBoards_filtersTemplateEntries_whenPayloadContainsMixedBoardTypes() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/workspaces/ws-1/boards", + method = "GET", + status = 200, + responseBody = + """ + [ + {"id":"board-1","name":"Roadmap"}, + {"id":"tmpl-1","name":"Template A","type":"template"}, + {"id":"tmpl-2","name":"Template B","kind":"template"}, + {"id":"tmpl-3","name":"Template C","isTemplate":true}, + {"id":"tmpl-4","name":"Template D","boardType":" Template "}, + {"publicId":"board-2","title":"Backlog"} + ] + """.trimIndent(), + ) + + val result = HttpKanbnApiClient().listBoards(server.baseUrl, "api", "ws-1") + + val boards = requireBoards(result) + assertEquals(listOf("board-1", "board-2"), boards.map { it.id }) + } + } + + @Test + fun listBoards_returnsEmpty_whenPayloadContainsOnlyTemplates() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/workspaces/ws-1/boards", + method = "GET", + status = 200, + responseBody = + """ + { + "boards": [ + {"id":"tmpl-1","name":"Template A","type":"template"}, + {"id":"tmpl-2","name":"Template B","kind":"template"}, + {"id":"tmpl-3","name":"Template C","isTemplate":true} + ] + } + """.trimIndent(), + ) + + val result = HttpKanbnApiClient().listBoards(server.baseUrl, "api", "ws-1") + + val boards = requireBoards(result) + assertTrue(boards.isEmpty()) + } + } + + @Test + fun listBoardTemplates_stillReturnsTemplates_fromTemplateEndpoint() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/workspaces/ws-1/boards?type=template", + method = "GET", + status = 200, + responseBody = + """ + { + "templates": [ + {"id":"tmpl-1","name":"Template A","type":"template"}, + {"public_id":"tmpl-2","title":"Template B","kind":"template"} + ] + } + """.trimIndent(), + ) + + val result = HttpKanbnApiClient().listBoardTemplates(server.baseUrl, "api", "ws-1") + + val templates = requireTemplates(result) + assertEquals(listOf("tmpl-1", "tmpl-2"), templates.map { it.id }) + } + } + + @Test + fun listBoards_keepsRegularBoardsUnchanged_whenNoTemplateMarkersExist() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/workspaces/ws-1/boards", + method = "GET", + status = 200, + responseBody = + """ + [ + {"id":"board-a","title":"A"}, + {"publicId":"board-b","name":"B"} + ] + """.trimIndent(), + ) + + val result = HttpKanbnApiClient().listBoards(server.baseUrl, "api", "ws-1") + + val boards = requireBoards(result) + assertEquals( + listOf( + BoardSummary(id = "board-a", title = "A"), + BoardSummary(id = "board-b", title = "B"), + ), + boards, + ) + } + } + + private fun requireBoards(result: BoardsApiResult>): List { + return when (result) { + is BoardsApiResult.Success -> result.value + is BoardsApiResult.Failure -> throw AssertionError("Expected boards success, got failure: ${result.message}") + } + } + + private fun requireTemplates(result: BoardsApiResult>): List { + return when (result) { + is BoardsApiResult.Success -> result.value + is BoardsApiResult.Failure -> throw AssertionError("Expected templates success, got failure: ${result.message}") + } + } + + private data class CapturedRequest( + val method: String, + val path: String, + val body: String, + val apiKey: String?, + ) + + private class TestServer : AutoCloseable { + private val requests = CopyOnWriteArrayList() + private val responses = mutableMapOf>() + private val responseSequences = mutableMapOf>>() + private val running = AtomicBoolean(true) + private val serverSocket = ServerSocket().apply { + bind(InetSocketAddress("127.0.0.1", 0)) + } + private val executor = Executors.newSingleThreadExecutor() + + val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}" + + init { + executor.execute { + while (running.get()) { + val socket = try { + serverSocket.accept() + } catch (_: Throwable) { + if (!running.get()) { + break + } + continue + } + handle(socket) + } + } + } + + fun register(path: String, status: Int, responseBody: String) { + register(path = path, method = "GET", status = status, responseBody = responseBody) + register(path = path, method = "PATCH", status = status, responseBody = responseBody) + register(path = path, method = "PUT", status = status, responseBody = responseBody) + register(path = path, method = "DELETE", status = status, responseBody = responseBody) + register(path = path, method = "POST", status = status, responseBody = responseBody) + } + + fun register(path: String, method: String, status: Int, responseBody: String) { + responses["${method.uppercase()} $path"] = status to responseBody + } + + fun registerSequence(path: String, method: String, responses: List>) { + responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses) + } + + fun findRequest(method: String, path: String): CapturedRequest? { + return requests.firstOrNull { it.method == method && it.path == path } + } + + private fun handle(socket: Socket) { + socket.use { s -> + s.soTimeout = 3_000 + val input = BufferedInputStream(s.getInputStream()) + val output = s.getOutputStream() + + val requestLine = readHttpLine(input).orEmpty() + if (requestLine.isBlank()) { + return + } + val parts = requestLine.split(" ") + val method = parts.getOrNull(0).orEmpty() + val path = parts.getOrNull(1).orEmpty() + + var apiKey: String? = null + var contentLength = 0 + var methodOverride: String? = null + while (true) { + val line = readHttpLine(input).orEmpty() + if (line.isBlank()) { + break + } + val separatorIndex = line.indexOf(':') + if (separatorIndex <= 0) { + continue + } + val headerName = line.substring(0, separatorIndex).trim().lowercase() + val headerValue = line.substring(separatorIndex + 1).trim() + if (headerName == "x-api-key") { + apiKey = headerValue + } else if (headerName == "x-http-method-override") { + methodOverride = headerValue + } else if (headerName == "content-length") { + contentLength = headerValue.toIntOrNull() ?: 0 + } + } + + val bodyBytes = if (contentLength > 0) ByteArray(contentLength) else ByteArray(0) + if (contentLength > 0) { + var total = 0 + while (total < contentLength) { + val read = input.read(bodyBytes, total, contentLength - total) + if (read <= 0) { + break + } + total += read + } + } + val body = String(bodyBytes) + val effectiveMethod = methodOverride ?: method + requests += CapturedRequest(method = effectiveMethod, path = path, body = body, apiKey = apiKey) + + val sequenceKey = "$effectiveMethod $path" + val sequence = responseSequences[sequenceKey] + val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null + val response = sequencedResponse ?: responses[sequenceKey] ?: responses["$method $path"] ?: (404 to "") + writeResponse(output, response.first, response.second) + } + } + + private fun writeResponse(output: OutputStream, status: Int, body: String) { + val bytes = body.toByteArray() + val reason = when (status) { + 200 -> "OK" + 400 -> "Bad Request" + 403 -> "Forbidden" + 409 -> "Conflict" + 404 -> "Not Found" + 500 -> "Internal Server Error" + 502 -> "Bad Gateway" + 503 -> "Service Unavailable" + else -> "Error" + } + val responseHeaders = + "HTTP/1.1 $status $reason\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: ${bytes.size}\r\n" + + "Connection: close\r\n\r\n" + output.write(responseHeaders.toByteArray()) + output.write(bytes) + output.flush() + } + + private fun readHttpLine(input: BufferedInputStream): String? { + val builder = StringBuilder() + while (true) { + val next = input.read() + if (next == -1) { + return if (builder.isEmpty()) null else builder.toString() + } + if (next == '\n'.code) { + if (builder.isNotEmpty() && builder.last() == '\r') { + builder.deleteCharAt(builder.length - 1) + } + return builder.toString() + } + builder.append(next.toChar()) + } + } + + override fun close() { + running.set(false) + serverSocket.close() + executor.shutdownNow() + executor.awaitTermination(3, TimeUnit.SECONDS) + } + } +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt index 7798f3f..87840c5 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsRepositoryTest.kt @@ -109,6 +109,65 @@ class BoardsRepositoryTest { assertEquals("No workspaces available for this account.", (result as BoardsApiResult.Failure).message) } + @Test + fun listBoardsExcludesBoardsWhoseIdsMatchTemplates() = runTest { + val fakeApi = FakeBoardsApiClient().apply { + workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + listBoardsResult = BoardsApiResult.Success( + listOf( + BoardSummary("board-1", "Juegos"), + BoardSummary("tpl-1", "GTD Simplificado"), + BoardSummary("tpl-2", "Kanban"), + BoardSummary("board-2", "Task Tension"), + ), + ) + listTemplatesResult = BoardsApiResult.Success( + listOf( + BoardTemplate("tpl-1", "GTD Simplificado"), + BoardTemplate("tpl-2", "Kanban"), + ), + ) + } + + val repository = BoardsRepository( + sessionStore = InMemorySessionStore("https://kan.bn/"), + apiKeyStore = InMemoryApiKeyStore("api"), + apiClient = fakeApi, + ) + + val result = repository.listBoards() + + assertTrue(result is BoardsApiResult.Success) + val boards = (result as BoardsApiResult.Success).value + assertEquals(listOf("board-1", "board-2"), boards.map { it.id }) + } + + @Test + fun listBoardsReturnsApiBoardsWhenTemplateListingFails() = runTest { + val fakeApi = FakeBoardsApiClient().apply { + workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main"))) + listBoardsResult = BoardsApiResult.Success( + listOf( + BoardSummary("board-1", "Juegos"), + BoardSummary("tpl-1", "GTD Simplificado"), + ), + ) + listTemplatesResult = BoardsApiResult.Failure("Template endpoint unavailable") + } + + val repository = BoardsRepository( + sessionStore = InMemorySessionStore("https://kan.bn/"), + apiKeyStore = InMemoryApiKeyStore("api"), + apiClient = fakeApi, + ) + + val result = repository.listBoards() + + assertTrue(result is BoardsApiResult.Success) + val boards = (result as BoardsApiResult.Success).value + assertEquals(listOf("board-1", "tpl-1"), boards.map { it.id }) + } + @Test fun createBoardTrimsNameAndPassesTemplateId() = runTest { val fakeApi = FakeBoardsApiClient().apply {