feat: add board detail and card/list mutation API methods

This commit is contained in:
2026-03-16 00:20:42 -04:00
parent 3cff919222
commit 6ea0bd1a2f
7 changed files with 874 additions and 9 deletions

View File

@@ -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<BoardDetail> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun renameList(
baseUrl: String,
apiKey: String,
listId: String,
newTitle: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,
cardId: String,
targetListId: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
}
}

View File

@@ -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<BoardDetail> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun renameList(
baseUrl: String,
apiKey: String,
listId: String,
newTitle: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,
cardId: String,
targetListId: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
}
}

View File

@@ -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<Unit> {
return BoardsApiResult.Failure("Board deletion is not implemented.")
}
suspend fun getBoardDetail(baseUrl: String, apiKey: String, boardId: String): BoardsApiResult<BoardDetail> {
return BoardsApiResult.Failure("Board detail is not implemented.")
}
suspend fun renameList(baseUrl: String, apiKey: String, listId: String, newTitle: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("List rename is not implemented.")
}
suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Card move is not implemented.")
}
suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
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<BoardDetail> {
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<Unit> {
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<Unit> {
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<Unit> {
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 <T> request(
baseUrl: String,
path: String,
@@ -203,7 +312,7 @@ class HttpKanbnApiClient : KanbnApiClient {
): BoardsApiResult<T> {
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<BoardListDetail> {
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<BoardCardSummary> {
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<BoardTagSummary> {
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<Map<*, *>> {
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<String, Any?>? {
val trimmed = body.trim()
if (trimmed.isBlank()) {
return null
}
val parsed = runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull()
@Suppress("UNCHECKED_CAST")
return parsed as? Map<String, Any?>
}
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<String, Any?> {
expect('{')
skipWhitespace()
val result = linkedMapOf<String, Any?>()
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<Any?> {
expect('[')
skipWhitespace()
val result = mutableListOf<Any?>()
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" }
}
}

View File

@@ -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<CapturedRequest>()
private val responses = mutableMapOf<String, 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) {
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)
}
}
}

View File

@@ -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<BoardDetail> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun renameList(
baseUrl: String,
apiKey: String,
listId: String,
newTitle: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,
cardId: String,
targetListId: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
}
}

View File

@@ -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<BoardDetail> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun renameList(
baseUrl: String,
apiKey: String,
listId: String,
newTitle: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,
cardId: String,
targetListId: String,
): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
override suspend fun deleteCard(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Not used in this test")
}
}
}