fix: keep template boards out of boards list
This commit is contained in:
@@ -915,6 +915,9 @@ class HttpKanbnApiClient : KanbnApiClient {
|
||||
val boards = mutableListOf<BoardSummary>()
|
||||
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") }
|
||||
@@ -931,6 +934,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)
|
||||
|
||||
@@ -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<List<BoardTemplate>> {
|
||||
|
||||
+305
@@ -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<BoardSummary>>): List<BoardSummary> {
|
||||
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<BoardTemplate>>): List<BoardTemplate> {
|
||||
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<CapturedRequest>()
|
||||
private val responses = mutableMapOf<String, Pair<Int, String>>()
|
||||
private val responseSequences = mutableMapOf<String, ArrayDeque<Pair<Int, String>>>()
|
||||
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<Pair<Int, String>>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user