feat: add board detail and card/list mutation API methods
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user