diff --git a/AGENTS.md b/AGENTS.md index 604415f..1809c0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - Compile/target SDK: API 35. - Baseline tests: - JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`. + - JVM unit tests for board detail parsing and board/list/card API request mapping in `app/src/test/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt`. - JVM unit tests for boards repository and boards view model in `app/src/test/space/hackenslacker/kanbn4droid/app/boards/`. - Instrumentation tests for login and boards flows in `app/src/androidTest/`. diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt index fbd6c8e..55d51eb 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardsFlowTest.kt @@ -24,6 +24,7 @@ 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.boarddetail.BoardDetail import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @@ -199,5 +200,31 @@ class BoardsFlowTest { boards.removeAll { it.id == boardId } return BoardsApiResult.Success(Unit) } + + override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } } } diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt index 67350a9..57a8e93 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt @@ -22,6 +22,8 @@ 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.boarddetail.BoardDetail +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @RunWith(AndroidJUnit4::class) class LoginFlowTest { @@ -200,5 +202,31 @@ class LoginFlowTest { private val result: AuthResult, ) : KanbnApiClient { override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result + + override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } } } 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 b673917..ab8c586 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 @@ -3,10 +3,15 @@ package space.hackenslacker.kanbn4droid.app.auth import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import java.time.Instant import org.json.JSONArray import org.json.JSONObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult @@ -48,6 +53,22 @@ interface KanbnApiClient { suspend fun deleteBoard(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { return BoardsApiResult.Failure("Board deletion is not implemented.") } + + suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Board detail is not implemented.") + } + + suspend fun renameList(baseUrl: String, apiKey: String, listId: String, newTitle: String): BoardsApiResult { + return BoardsApiResult.Failure("List rename is not implemented.") + } + + suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult { + return BoardsApiResult.Failure("Card move is not implemented.") + } + + suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Card deletion is not implemented.") + } } class HttpKanbnApiClient : KanbnApiClient { @@ -193,6 +214,94 @@ class HttpKanbnApiClient : KanbnApiClient { } } + override suspend fun getBoardDetail( + baseUrl: String, + apiKey: String, + boardId: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/boards/$boardId", + method = "GET", + apiKey = apiKey, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(parseBoardDetail(body, boardId)) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/lists/$listId", + method = "PATCH", + apiKey = apiKey, + body = "{\"name\":\"${jsonEscape(newTitle.trim())}\"}", + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "PATCH", + apiKey = apiKey, + body = "{\"listId\":\"${jsonEscape(targetListId)}\"}", + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun deleteCard( + baseUrl: String, + apiKey: String, + cardId: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "DELETE", + apiKey = apiKey, + ) { code, body -> + if (code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + private fun request( baseUrl: String, path: String, @@ -203,7 +312,7 @@ class HttpKanbnApiClient : KanbnApiClient { ): BoardsApiResult { val endpoint = "${baseUrl.trimEnd('/')}$path" val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply { - requestMethod = method + configureRequestMethod(this, method) connectTimeout = 10_000 readTimeout = 10_000 setRequestProperty("x-api-key", apiKey) @@ -237,6 +346,18 @@ class HttpKanbnApiClient : KanbnApiClient { } } + private fun configureRequestMethod(connection: HttpURLConnection, method: String) { + try { + connection.requestMethod = method + } catch (throwable: Throwable) { + if (method != "PATCH") { + throw throwable + } + connection.requestMethod = "POST" + connection.setRequestProperty("X-HTTP-Method-Override", "PATCH") + } + } + private fun readResponseBody(connection: HttpURLConnection, code: Int): String { val stream = if (code in 200..299) connection.inputStream else connection.errorStream return stream?.bufferedReader()?.use { it.readText() }.orEmpty() @@ -361,18 +482,295 @@ class HttpKanbnApiClient : KanbnApiClient { return workspaces } + private fun parseBoardDetail(body: String, fallbackId: String): BoardDetail { + val root = parseJsonObject(body) + ?: return BoardDetail(id = fallbackId, title = "Board", lists = emptyList()) + val data = root["data"] as? Map<*, *> + val board = (data?.get("board") as? Map<*, *>) + ?: (root["board"] as? Map<*, *>) + ?: root + + val boardId = extractId(board).ifBlank { fallbackId } + val boardTitle = extractTitle(board, "Board") + val lists = parseLists(board) + + return BoardDetail(id = boardId, title = boardTitle, lists = lists) + } + + private fun parseLists(board: Map<*, *>): List { + return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList -> + val id = extractId(rawList) + if (id.isBlank()) { + return@mapNotNull null + } + BoardListDetail( + id = id, + title = extractTitle(rawList, "List"), + cards = parseCards(rawList), + ) + } + } + + private fun parseCards(list: Map<*, *>): List { + return extractObjectArray(list, "cards", "items", "data").mapNotNull { rawCard -> + val id = extractId(rawCard) + if (id.isBlank()) { + return@mapNotNull null + } + BoardCardSummary( + id = id, + title = extractTitle(rawCard, "Card"), + tags = parseTags(rawCard), + dueAtEpochMillis = parseDueDate(rawCard), + ) + } + } + + private fun parseTags(card: Map<*, *>): List { + return extractObjectArray(card, "labels", "tags", "data").mapNotNull { rawTag -> + val id = extractId(rawTag) + if (id.isBlank()) { + return@mapNotNull null + } + BoardTagSummary( + id = id, + name = extractTitle(rawTag, "Tag"), + colorHex = extractString(rawTag, "colorHex", "color", "hex"), + ) + } + } + + private fun parseDueDate(card: Map<*, *>): Long? { + val dueValue = firstPresent(card, "dueDate", "dueAt", "due_at", "due") ?: return null + return when (dueValue) { + is Number -> dueValue.toLong() + is String -> { + val trimmed = dueValue.trim() + if (trimmed.isBlank()) null else trimmed.toLongOrNull() ?: runCatching { Instant.parse(trimmed).toEpochMilli() }.getOrNull() + } + + else -> null + } + } + + private fun extractObjectArray(source: Map<*, *>, vararg keys: String): List> { + val array = keys.firstNotNullOfOrNull { key -> source[key] as? List<*> } ?: return emptyList() + return array.mapNotNull { it as? Map<*, *> } + } + + private fun extractId(source: Map<*, *>): String { + val directId = source["id"]?.toString().orEmpty() + if (directId.isNotBlank()) { + return directId + } + return extractString(source, "publicId", "public_id") + } + + private fun extractTitle(source: Map<*, *>, fallback: String): String { + return extractString(source, "title", "name").ifBlank { fallback } + } + + private fun extractString(source: Map<*, *>, vararg keys: String): String { + return keys.firstNotNullOfOrNull { key -> source[key]?.toString()?.takeIf { it.isNotBlank() } }.orEmpty() + } + + private fun firstPresent(source: Map<*, *>, vararg keys: String): Any? { + return keys.firstNotNullOfOrNull { key -> source[key] } + } + + private fun parseJsonObject(body: String): Map? { + val trimmed = body.trim() + if (trimmed.isBlank()) { + return null + } + val parsed = runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull() + @Suppress("UNCHECKED_CAST") + return parsed as? Map + } + + private fun jsonEscape(value: String): String { + val builder = StringBuilder() + value.forEach { ch -> + when (ch) { + '\\' -> builder.append("\\\\") + '"' -> builder.append("\\\"") + '\b' -> builder.append("\\b") + '\u000C' -> builder.append("\\f") + '\n' -> builder.append("\\n") + '\r' -> builder.append("\\r") + '\t' -> builder.append("\\t") + else -> builder.append(ch) + } + } + return builder.toString() + } + + private class MiniJsonParser(private val input: String) { + private var index = 0 + + fun parseValue(): Any? { + skipWhitespace() + if (index >= input.length) { + return null + } + return when (val ch = input[index]) { + '{' -> parseObject() + '[' -> parseArray() + '"' -> parseString() + 't' -> parseLiteral("true", true) + 'f' -> parseLiteral("false", false) + 'n' -> parseLiteral("null", null) + '-', in '0'..'9' -> parseNumber() + else -> throw IllegalArgumentException("Unexpected token $ch at index $index") + } + } + + private fun parseObject(): Map { + expect('{') + skipWhitespace() + val result = linkedMapOf() + if (peek() == '}') { + index += 1 + return result + } + while (index < input.length) { + val key = parseString() + skipWhitespace() + expect(':') + val value = parseValue() + result[key] = value + skipWhitespace() + when (peek()) { + ',' -> index += 1 + '}' -> { + index += 1 + return result + } + else -> throw IllegalArgumentException("Expected , or } at index $index") + } + skipWhitespace() + } + throw IllegalArgumentException("Unclosed object") + } + + private fun parseArray(): List { + expect('[') + skipWhitespace() + val result = mutableListOf() + if (peek() == ']') { + index += 1 + return result + } + while (index < input.length) { + result += parseValue() + skipWhitespace() + when (peek()) { + ',' -> index += 1 + ']' -> { + index += 1 + return result + } + else -> throw IllegalArgumentException("Expected , or ] at index $index") + } + skipWhitespace() + } + throw IllegalArgumentException("Unclosed array") + } + + private fun parseString(): String { + expect('"') + val result = StringBuilder() + while (index < input.length) { + val ch = input[index++] + when (ch) { + '"' -> return result.toString() + '\\' -> { + val escaped = input.getOrNull(index++) ?: throw IllegalArgumentException("Invalid escape") + when (escaped) { + '"' -> result.append('"') + '\\' -> result.append('\\') + '/' -> result.append('/') + 'b' -> result.append('\b') + 'f' -> result.append('\u000C') + 'n' -> result.append('\n') + 'r' -> result.append('\r') + 't' -> result.append('\t') + 'u' -> { + val hex = input.substring(index, index + 4) + index += 4 + result.append(hex.toInt(16).toChar()) + } + else -> throw IllegalArgumentException("Invalid escape token") + } + } + else -> result.append(ch) + } + } + throw IllegalArgumentException("Unclosed string") + } + + private fun parseNumber(): Any { + val start = index + if (peek() == '-') { + index += 1 + } + while (peek()?.isDigit() == true) { + index += 1 + } + var isFloating = false + if (peek() == '.') { + isFloating = true + index += 1 + while (peek()?.isDigit() == true) { + index += 1 + } + } + if (peek() == 'e' || peek() == 'E') { + isFloating = true + index += 1 + if (peek() == '+' || peek() == '-') { + index += 1 + } + while (peek()?.isDigit() == true) { + index += 1 + } + } + val token = input.substring(start, index) + return if (isFloating) token.toDouble() else token.toLong() + } + + private fun parseLiteral(token: String, value: Any?): Any? { + if (!input.startsWith(token, index)) { + throw IllegalArgumentException("Expected $token at index $index") + } + index += token.length + return value + } + + private fun expect(expected: Char) { + skipWhitespace() + if (peek() != expected) { + throw IllegalArgumentException("Expected $expected at index $index") + } + index += 1 + } + + private fun peek(): Char? = input.getOrNull(index) + + private fun skipWhitespace() { + while (peek()?.isWhitespace() == true) { + index += 1 + } + } + } + private fun serverMessage(body: String, code: Int): String { if (body.isBlank()) { return "Server error: $code" } - return runCatching { - val root = JSONObject(body) - listOf("message", "error", "cause", "detail") - .firstNotNullOfOrNull { key -> root.optString(key).takeIf { it.isNotBlank() } } - ?: "Server error: $code" - }.getOrElse { - "Server error: $code" - } + val root = parseJsonObject(body) + val message = root?.let { extractString(it, "message", "error", "cause", "detail") }.orEmpty() + return message.ifBlank { "Server error: $code" } } } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt new file mode 100644 index 0000000..5352c43 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/HttpKanbnApiClientBoardDetailParsingTest.kt @@ -0,0 +1,357 @@ +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.time.Instant +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.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail +import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult + +class HttpKanbnApiClientBoardDetailParsingTest { + + @Test + fun getBoardDetailParsesWrappedPayloadWithDueDateVariants() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/board-1", + status = 200, + responseBody = + """ + { + "data": { + "board": { + "public_id": "board-1", + "name": "Roadmap", + "items": [ + { + "id": "list-1", + "name": "Todo", + "cards": [ + { + "publicId": "card-iso", + "name": "ISO", + "tags": [{"id": "tag-1", "name": "Urgent", "color": "#FF0000"}], + "dueAt": "2026-01-05T08:30:00Z" + }, + { + "public_id": "card-epoch-num", + "title": "EpochNum", + "labels": [{"public_id": "tag-2", "title": "Backend", "colorHex": "#00FF00"}], + "due": 1735689600000 + } + ] + }, + { + "publicId": "list-2", + "title": "Doing", + "data": [ + { + "id": "card-epoch-string", + "title": "EpochString", + "data": [{"id": "tag-3", "name": "Ops", "hex": "#0000FF"}], + "due_at": "1735689600123" + }, + { + "id": "card-invalid", + "name": "Invalid", + "labels": [], + "dueDate": "not-a-date" + } + ] + } + ] + } + } + } + """.trimIndent(), + ) + + val client = HttpKanbnApiClient() + + val result = client.getBoardDetail(server.baseUrl, "api-key", "board-1") + + assertTrue(result is BoardsApiResult.Success<*>) + val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail + assertEquals("board-1", detail.id) + assertEquals("Roadmap", detail.title) + assertEquals(2, detail.lists.size) + assertEquals("list-1", detail.lists[0].id) + assertEquals("Todo", detail.lists[0].title) + assertEquals("card-iso", detail.lists[0].cards[0].id) + assertEquals(Instant.parse("2026-01-05T08:30:00Z").toEpochMilli(), detail.lists[0].cards[0].dueAtEpochMillis) + assertEquals(1735689600000L, detail.lists[0].cards[1].dueAtEpochMillis) + assertEquals("tag-1", detail.lists[0].cards[0].tags[0].id) + assertEquals("Urgent", detail.lists[0].cards[0].tags[0].name) + assertEquals("#FF0000", detail.lists[0].cards[0].tags[0].colorHex) + assertEquals(1735689600123L, detail.lists[1].cards[0].dueAtEpochMillis) + assertNull(detail.lists[1].cards[1].dueAtEpochMillis) + } + } + + @Test + fun getBoardDetailParsesDirectRootObject() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/b-2", + status = 200, + responseBody = + """ + { + "id": "b-2", + "title": "Board Direct", + "lists": [ + { + "id": "l-1", + "title": "List", + "cards": [ + { + "id": "c-1", + "title": "Card", + "labels": [{"id": "t-1", "name": "Tag", "color": "#111111"}] + } + ] + } + ] + } + """.trimIndent(), + ) + + val result = HttpKanbnApiClient().getBoardDetail(server.baseUrl, "key", "b-2") + + assertTrue(result is BoardsApiResult.Success<*>) + val detail = (result as BoardsApiResult.Success<*>).value as BoardDetail + assertEquals("b-2", detail.id) + assertEquals("Board Direct", detail.title) + assertEquals(1, detail.lists.size) + assertEquals("l-1", detail.lists[0].id) + assertEquals("c-1", detail.lists[0].cards[0].id) + assertEquals("t-1", detail.lists[0].cards[0].tags[0].id) + } + } + + @Test + fun requestMappingMatchesContractForBoardDetailAndMutations() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/boards/b-1", + status = 200, + responseBody = """{"id":"b-1","title":"Board","lists":[]}""", + ) + server.register(path = "/api/v1/lists/l-1", status = 200, responseBody = "{}") + server.register(path = "/api/v1/cards/c-1", status = 200, responseBody = "{}") + server.register(path = "/api/v1/cards/c-2", status = 200, responseBody = "{}") + + val client = HttpKanbnApiClient() + val boardResult = client.getBoardDetail(server.baseUrl, "api-123", "b-1") + val renameResult = client.renameList(server.baseUrl, "api-123", "l-1", " New title ") + val moveResult = client.moveCard(server.baseUrl, "api-123", "c-1", "l-9") + val deleteResult = client.deleteCard(server.baseUrl, "api-123", "c-2") + + assertTrue(boardResult is BoardsApiResult.Success<*>) + assertTrue(renameResult is BoardsApiResult.Success<*>) + assertTrue(moveResult is BoardsApiResult.Success<*>) + assertTrue(deleteResult is BoardsApiResult.Success<*>) + + val boardRequest = server.findRequest("GET", "/api/v1/boards/b-1") + assertNotNull(boardRequest) + assertEquals("api-123", boardRequest?.apiKey) + + val renameRequest = server.findRequest("PATCH", "/api/v1/lists/l-1") + assertNotNull(renameRequest) + assertEquals("{\"name\":\"New title\"}", renameRequest?.body) + assertEquals("api-123", renameRequest?.apiKey) + + val moveRequest = server.findRequest("PATCH", "/api/v1/cards/c-1") + assertNotNull(moveRequest) + assertEquals("{\"listId\":\"l-9\"}", moveRequest?.body) + assertEquals("api-123", moveRequest?.apiKey) + + val deleteRequest = server.findRequest("DELETE", "/api/v1/cards/c-2") + assertNotNull(deleteRequest) + assertEquals("api-123", deleteRequest?.apiKey) + } + } + + @Test + fun serverMessageIsPropagatedWithFallbackWhenMissing() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/lists/l-error", + status = 400, + responseBody = """{"message":"List is locked"}""", + ) + server.register( + path = "/api/v1/lists/l-fallback", + status = 503, + responseBody = "{}", + ) + + val client = HttpKanbnApiClient() + val messageResult = client.renameList(server.baseUrl, "api", "l-error", "Name") + val fallbackResult = client.renameList(server.baseUrl, "api", "l-fallback", "Name") + + assertTrue(messageResult is BoardsApiResult.Failure) + assertEquals("List is locked", (messageResult as BoardsApiResult.Failure).message) + assertTrue(fallbackResult is BoardsApiResult.Failure) + assertEquals("Server error: 503", (fallbackResult as BoardsApiResult.Failure).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 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) { + responses["GET $path"] = status to responseBody + responses["PATCH $path"] = status to responseBody + responses["DELETE $path"] = status to responseBody + responses["POST $path"] = status to responseBody + } + + 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 response = responses["$effectiveMethod $path"] ?: 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" + 404 -> "Not Found" + 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 2aabe86..7fbe893 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 @@ -8,6 +8,7 @@ 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.boarddetail.BoardDetail class BoardsRepositoryTest { @@ -250,5 +251,31 @@ class BoardsRepositoryTest { lastDeletedId = boardId return deleteBoardResult } + + override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } } } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt index a5d901e..799c73c 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boards/BoardsViewModelTest.kt @@ -19,6 +19,7 @@ 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.boarddetail.BoardDetail @OptIn(ExperimentalCoroutinesApi::class) class BoardsViewModelTest { @@ -194,5 +195,31 @@ class BoardsViewModelTest { lastDeletedId = boardId return deleteBoardResult } + + override suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun renameList( + baseUrl: String, + apiKey: String, + listId: String, + newTitle: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun moveCard( + baseUrl: String, + apiKey: String, + cardId: String, + targetListId: String, + ): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } + + override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Not used in this test") + } } }