From fb5d9e1e5b74da3309c3fb47d60aeff7612b24d7 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 20:22:36 -0400 Subject: [PATCH] feat: add card detail API contracts and compatibility parsing --- AGENTS.md | 2 +- .../kanbn4droid/app/auth/KanbnApiClient.kt | 505 +++++++++++++++++- .../app/carddetail/CardDetailModels.kt | 26 + .../auth/CardDetailApiCompatibilityTest.kt | 399 ++++++++++++++ 4 files changed, 928 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailModels.kt create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/CardDetailApiCompatibilityTest.kt diff --git a/AGENTS.md b/AGENTS.md index 59e9b30..2911e57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel - The modal dialog has "Add comment" as a title. - The modal dialog has an editable markdown-enabled text field for the comment. - The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively. -- Current status: full card detail is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras. +- Current status: full card detail UI is still pending. Card-detail domain models and API contracts are now present (`CardDetail`, `CardActivity`, tags), with compatibility behavior implemented in `HttpKanbnApiClient` for card detail fetch, card update fallback variants with auth short-circuit, activity endpoint fallback parsing, and add-comment fallback/logical-failure handling. **Settings view** - The view shows a list of settings that can be changed by the user. The following settings are available: 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 06c6977..fa99398 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 @@ -5,6 +5,8 @@ import java.net.HttpURLConnection import java.net.URL import java.time.Instant import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneOffset import org.json.JSONArray import org.json.JSONObject import kotlinx.coroutines.Dispatchers @@ -18,6 +20,9 @@ import space.hackenslacker.kanbn4droid.app.boards.BoardSummary import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary +import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity +import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail +import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailTag interface KanbnApiClient { suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult @@ -97,6 +102,29 @@ interface KanbnApiClient { suspend fun getLabelByPublicId(baseUrl: String, apiKey: String, labelId: String): BoardsApiResult { return BoardsApiResult.Failure("Label detail is not implemented.") } + + suspend fun getCardDetail(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult { + return BoardsApiResult.Failure("Card detail is not implemented.") + } + + suspend fun updateCard( + baseUrl: String, + apiKey: String, + cardId: String, + title: String, + description: String, + dueDate: LocalDate?, + ): BoardsApiResult { + return BoardsApiResult.Failure("Card update is not implemented.") + } + + suspend fun listCardActivities(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult> { + return BoardsApiResult.Failure("Card activity listing is not implemented.") + } + + suspend fun addCardComment(baseUrl: String, apiKey: String, cardId: String, comment: String): BoardsApiResult { + return BoardsApiResult.Failure("Card comment creation is not implemented.") + } } data class LabelDetail( @@ -547,6 +575,198 @@ class HttpKanbnApiClient : KanbnApiClient { } } + override suspend fun getCardDetail( + baseUrl: String, + apiKey: String, + cardId: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + request( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "GET", + apiKey = apiKey, + ) { code, body -> + if (code in 200..299) { + parseCardDetail(body, fallbackId = cardId) + ?.let { BoardsApiResult.Success(it) } + ?: BoardsApiResult.Failure("Malformed card detail response.") + } else { + BoardsApiResult.Failure(serverMessage(body, code)) + } + } + } + } + + override suspend fun updateCard( + baseUrl: String, + apiKey: String, + cardId: String, + title: String, + description: String, + dueDate: LocalDate?, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + val canonicalPayload = buildCardMutationPayload(title, description, dueDate, aliasKeys = false) + val aliasPayload = buildCardMutationPayload(title, description, dueDate, aliasKeys = true) + + val attempts = listOf( + "PATCH" to canonicalPayload, + "PATCH" to aliasPayload, + "PUT" to canonicalPayload, + "PUT" to aliasPayload, + ) + + var lastFailureMessage = "Card update failed." + for ((method, payload) in attempts) { + val response = requestRaw( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = method, + apiKey = apiKey, + body = payload, + ) + if (response == null) { + return@withContext BoardsApiResult.Failure("Card update failed.") + } + if (response.code in 200..299) { + return@withContext BoardsApiResult.Success(Unit) + } + if (response.code == 401 || response.code == 403) { + return@withContext BoardsApiResult.Failure(serverMessage(response.body, response.code)) + } + lastFailureMessage = serverMessage(response.body, response.code) + } + + val getResponse = requestRaw( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "GET", + apiKey = apiKey, + ) + if (getResponse == null) { + return@withContext BoardsApiResult.Failure(lastFailureMessage) + } + if (getResponse.code == 401 || getResponse.code == 403) { + return@withContext BoardsApiResult.Failure(serverMessage(getResponse.body, getResponse.code)) + } + if (getResponse.code !in 200..299) { + return@withContext BoardsApiResult.Failure(serverMessage(getResponse.body, getResponse.code)) + } + + val fullPutPayload = buildCardFullPutPayload(getResponse.body, title, description, dueDate) + if (fullPutPayload == null) { + return@withContext BoardsApiResult.Failure(lastFailureMessage) + } + + val finalResponse = requestRaw( + baseUrl = baseUrl, + path = "/api/v1/cards/$cardId", + method = "PUT", + apiKey = apiKey, + body = fullPutPayload, + ) + if (finalResponse == null) { + return@withContext BoardsApiResult.Failure(lastFailureMessage) + } + if (finalResponse.code in 200..299) { + BoardsApiResult.Success(Unit) + } else { + BoardsApiResult.Failure(serverMessage(finalResponse.body, finalResponse.code)) + } + } + } + + override suspend fun listCardActivities( + baseUrl: String, + apiKey: String, + cardId: String, + ): BoardsApiResult> { + return withContext(Dispatchers.IO) { + val paths = listOf( + "/api/v1/cards/$cardId/activities?limit=50", + "/api/v1/cards/$cardId/actions?limit=50", + "/api/v1/cards/$cardId/card-activities?limit=50", + ) + + var lastFailure = "Card activities are not available." + for (path in paths) { + val response = requestRaw( + baseUrl = baseUrl, + path = path, + method = "GET", + apiKey = apiKey, + ) + if (response == null) { + continue + } + if (response.code in 200..299) { + val parsed = parseCardActivities(response.body) + if (parsed != null) { + val normalized = parsed + .sortedByDescending { it.createdAtEpochMillis } + .take(10) + return@withContext BoardsApiResult.Success(normalized) + } + continue + } + if (response.code == 401 || response.code == 403) { + return@withContext BoardsApiResult.Failure(serverMessage(response.body, response.code)) + } + lastFailure = serverMessage(response.body, response.code) + } + + BoardsApiResult.Failure(lastFailure) + } + } + + override suspend fun addCardComment( + baseUrl: String, + apiKey: String, + cardId: String, + comment: String, + ): BoardsApiResult { + return withContext(Dispatchers.IO) { + val attempts = listOf( + Pair("/api/v1/cards/$cardId/comment-actions", "{\"text\":\"${jsonEscape(comment)}\"}"), + Pair("/api/v1/cards/$cardId/comment-actions", "{\"comment\":\"${jsonEscape(comment)}\"}"), + Pair("/api/v1/cards/$cardId/actions/comments", "{\"text\":\"${jsonEscape(comment)}\"}"), + ) + + var lastFailure = "Comment could not be added." + for ((path, payload) in attempts) { + val response = requestRaw( + baseUrl = baseUrl, + path = path, + method = "POST", + apiKey = apiKey, + body = payload, + ) + if (response == null) { + continue + } + + if (response.code !in 200..299) { + if (response.code == 401 || response.code == 403) { + return@withContext BoardsApiResult.Failure(serverMessage(response.body, response.code)) + } + lastFailure = serverMessage(response.body, response.code) + continue + } + + when (evaluateCommentResponse(response.body)) { + CommentResponseClassification.Success -> return@withContext BoardsApiResult.Success(Unit) + CommentResponseClassification.LogicalFailure -> { + return@withContext BoardsApiResult.Failure(extractLogicalFailureMessage(response.body)) + } + CommentResponseClassification.ParseIncompatible -> continue + } + } + + BoardsApiResult.Failure(lastFailure) + } + } + private fun request( baseUrl: String, path: String, @@ -591,6 +811,48 @@ class HttpKanbnApiClient : KanbnApiClient { } } + private data class RawResponse(val code: Int, val body: String) + + private fun requestRaw( + baseUrl: String, + path: String, + method: String, + apiKey: String, + body: String? = null, + ): RawResponse? { + val endpoint = "${baseUrl.trimEnd('/')}$path" + val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply { + configureRequestMethod(this, method) + connectTimeout = 10_000 + readTimeout = 10_000 + setRequestProperty("x-api-key", apiKey) + if (body != null) { + doOutput = true + setRequestProperty("Content-Type", "application/json") + } + } + + return try { + if (body != null) { + connection.outputStream.bufferedWriter().use { writer -> writer.write(body) } + } + val code = connection.responseCode + RawResponse(code = code, body = readResponseBody(connection, code)) + } catch (_: Throwable) { + null + } finally { + try { + connection.inputStream?.close() + } catch (_: IOException) { + } + try { + connection.errorStream?.close() + } catch (_: IOException) { + } + connection.disconnect() + } + } + private fun configureRequestMethod(connection: HttpURLConnection, method: String) { try { connection.requestMethod = method @@ -789,6 +1051,239 @@ class HttpKanbnApiClient : KanbnApiClient { return CreatedEntityRef(publicId = publicId.ifBlank { null }) } + private fun parseCardDetail(body: String, fallbackId: String): CardDetail? { + if (body.isBlank()) { + return null + } + val root = parseJsonObject(body) ?: return null + val data = root["data"] as? Map<*, *> + val card = (data?.get("card") as? Map<*, *>) + ?: (root["card"] as? Map<*, *>) + ?: data + ?: root + + val id = extractId(card).ifBlank { fallbackId } + val title = extractString(card, "title", "name").ifBlank { "Card" } + val description = firstPresent(card, "description", "body")?.toString().orEmpty() + val dueDate = parseCardDueDate(firstPresent(card, "dueDate", "dueAt", "due_at", "due")) + val listPublicId = extractString(card, "listPublicId", "listId", "list_id").ifBlank { + (card["list"] as? Map<*, *>)?.let { extractString(it, "publicId", "public_id", "id") }.orEmpty() + }.ifBlank { null } + val index = parseCardIndex(firstPresent(card, "index", "position")) + val tags = extractObjectArray(card, "labels", "tags", "data").mapNotNull { rawTag -> + val tagId = extractId(rawTag) + if (tagId.isBlank()) { + return@mapNotNull null + } + CardDetailTag( + id = tagId, + name = extractTitle(rawTag, "Tag"), + colorHex = extractString(rawTag, "colourCode", "colorCode", "colorHex", "color", "hex"), + ) + } + + return CardDetail( + id = id, + title = title, + description = description, + dueDate = dueDate, + listPublicId = listPublicId, + index = index, + tags = tags, + ) + } + + private fun parseCardDueDate(raw: Any?): LocalDate? { + val value = raw?.toString()?.trim().orEmpty() + if (value.isBlank() || value.equals("null", ignoreCase = true)) { + return null + } + runCatching { return LocalDate.parse(value) } + runCatching { return OffsetDateTime.parse(value).withOffsetSameInstant(ZoneOffset.UTC).toLocalDate() } + runCatching { return Instant.parse(value).atOffset(ZoneOffset.UTC).toLocalDate() } + return null + } + + private fun parseCardIndex(raw: Any?): Int? { + return when (raw) { + is Number -> raw.toInt() + is String -> raw.toIntOrNull() ?: raw.toDoubleOrNull()?.toInt() + else -> null + } + } + + private fun buildCardMutationPayload( + title: String, + description: String, + dueDate: LocalDate?, + aliasKeys: Boolean, + ): String { + val dueValue = dueDate?.let { "\"${it}T00:00:00Z\"" } ?: "null" + return if (aliasKeys) { + "{" + + "\"name\":\"${jsonEscape(title)}\"," + + "\"body\":\"${jsonEscape(description)}\"," + + "\"dueAt\":$dueValue" + + "}" + } else { + "{" + + "\"title\":\"${jsonEscape(title)}\"," + + "\"description\":\"${jsonEscape(description)}\"," + + "\"dueDate\":$dueValue" + + "}" + } + } + + private fun buildCardFullPutPayload( + cardBody: String, + title: String, + description: String, + dueDate: LocalDate?, + ): String? { + if (cardBody.isBlank()) { + return null + } + + val root = parseJsonObject(cardBody) ?: return null + val data = root["data"] as? Map<*, *> + val card = (data?.get("card") as? Map<*, *>) + ?: (root["card"] as? Map<*, *>) + ?: data + ?: root + + val listPublicId = extractString(card, "listPublicId", "listId", "list_id").ifBlank { + (card["list"] as? Map<*, *>)?.let { extractString(it, "publicId", "public_id", "id") }.orEmpty() + } + if (listPublicId.isBlank()) { + return null + } + + val indexField = parseCardIndex(firstPresent(card, "index", "position")) ?: 0 + val dueField = dueDate?.let { "\"${it}T00:00:00Z\"" } ?: "null" + return "{" + + "\"title\":\"${jsonEscape(title)}\"," + + "\"description\":\"${jsonEscape(description)}\"," + + "\"dueDate\":$dueField," + + "\"index\":$indexField," + + "\"listPublicId\":\"${jsonEscape(listPublicId)}\"" + + "}" + } + + private fun parseCardActivities(body: String): List? { + if (body.isBlank()) { + return emptyList() + } + + val parsed = parseJsonValue(body) ?: return null + val items = when (parsed) { + is List<*> -> parsed.mapNotNull { it as? Map<*, *> } + is Map<*, *> -> extractActivityArray(parsed) + else -> return null + } + + if (items.isEmpty()) { + val trimmed = body.trim() + if (trimmed == "[]") { + return emptyList() + } + val asMap = parsed as? Map<*, *> ?: return emptyList() + val hadContainer = sequenceOf( + "activities", + "actions", + "cardActivities", + "card_activities", + "items", + "data", + ).any { key -> asMap.containsKey(key) || ((asMap["data"] as? Map<*, *>)?.containsKey(key) == true) } + if (hadContainer) { + return null + } + return emptyList() + } + + return items.mapNotNull { raw -> + val id = extractString(raw, "id", "publicId", "public_id") + if (id.isBlank()) { + return@mapNotNull null + } + val createdAtRaw = firstPresent(raw, "createdAt", "created_at", "date", "timestamp")?.toString().orEmpty() + val createdAt = parseEpochMillis(createdAtRaw) + ?: return@mapNotNull null + CardActivity( + id = id, + type = extractString(raw, "type", "actionType", "kind").ifBlank { "activity" }, + text = extractString(raw, "text", "comment", "message", "description", "body", "content"), + createdAtEpochMillis = createdAt, + ) + } + } + + private fun extractActivityArray(source: Map<*, *>): List> { + val topLevel = extractObjectArray(source, "activities", "actions", "cardActivities", "card_activities", "items", "data") + if (topLevel.isNotEmpty()) { + return topLevel + } + val data = source["data"] as? Map<*, *> ?: return emptyList() + return extractObjectArray(data, "activities", "actions", "cardActivities", "card_activities", "items", "data") + } + + private fun parseEpochMillis(raw: String): Long? { + val trimmed = raw.trim() + if (trimmed.isBlank()) { + return null + } + return trimmed.toLongOrNull() + ?: runCatching { Instant.parse(trimmed).toEpochMilli() }.getOrNull() + ?: runCatching { OffsetDateTime.parse(trimmed).toInstant().toEpochMilli() }.getOrNull() + } + + private enum class CommentResponseClassification { + Success, + LogicalFailure, + ParseIncompatible, + } + + private fun evaluateCommentResponse(body: String): CommentResponseClassification { + if (body.isBlank()) { + return CommentResponseClassification.Success + } + val root = parseJsonValue(body) as? Map<*, *> ?: return CommentResponseClassification.ParseIncompatible + + val explicitError = firstPresent(root, "error", "errors")?.toString().orEmpty() + if (explicitError.isNotBlank() && !explicitError.equals("null", ignoreCase = true)) { + return CommentResponseClassification.LogicalFailure + } + + val successValue = firstPresent(root, "success") + if (successValue is Boolean && !successValue) { + return CommentResponseClassification.LogicalFailure + } + val okValue = firstPresent(root, "ok") + if (okValue is Boolean && !okValue) { + return CommentResponseClassification.LogicalFailure + } + + val status = firstPresent(root, "status")?.toString()?.trim()?.lowercase().orEmpty() + if (status == "error" || status == "failed" || status == "failure") { + return CommentResponseClassification.LogicalFailure + } + + val successKeyPresent = sequenceOf("commentAction", "comment", "action", "data").any { key -> + root.containsKey(key) && root[key] != null + } + if (successKeyPresent) { + return CommentResponseClassification.Success + } + + return CommentResponseClassification.ParseIncompatible + } + + private fun extractLogicalFailureMessage(body: String): String { + val root = parseJsonValue(body) as? Map<*, *> ?: return "Comment could not be added." + val message = extractString(root, "message", "error", "detail", "cause") + return message.ifBlank { "Comment could not be added." } + } + private fun parseLists(board: Map<*, *>): List { return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList -> val id = extractId(rawList) @@ -871,13 +1366,17 @@ class HttpKanbnApiClient : KanbnApiClient { } private fun parseJsonObject(body: String): Map? { + val parsed = parseJsonValue(body) + @Suppress("UNCHECKED_CAST") + return parsed as? Map + } + + private fun parseJsonValue(body: 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 + return runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull() } private fun jsonEscape(value: String): String { diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailModels.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailModels.kt new file mode 100644 index 0000000..790fb8d --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailModels.kt @@ -0,0 +1,26 @@ +package space.hackenslacker.kanbn4droid.app.carddetail + +import java.time.LocalDate + +data class CardDetail( + val id: String, + val title: String, + val description: String, + val dueDate: LocalDate?, + val listPublicId: String?, + val index: Int?, + val tags: List, +) + +data class CardDetailTag( + val id: String, + val name: String, + val colorHex: String, +) + +data class CardActivity( + val id: String, + val type: String, + val text: String, + val createdAtEpochMillis: Long, +) diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/CardDetailApiCompatibilityTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/CardDetailApiCompatibilityTest.kt new file mode 100644 index 0000000..d1ff6b2 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/CardDetailApiCompatibilityTest.kt @@ -0,0 +1,399 @@ +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.LocalDate +import java.time.ZoneOffset +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.BoardsApiResult +import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity +import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail + +class CardDetailApiCompatibilityTest { + + @Test + fun getCardDetail_parsesNestedContainers_andNormalizesDueDateVariants() = runTest { + TestServer().use { server -> + server.registerSequence( + path = "/api/v1/cards/card-1", + method = "GET", + responses = listOf( + 200 to """ + { + "data": { + "card": { + "public_id": "card-1", + "name": "Direct date", + "body": "Desc", + "dueDate": "2026-03-16", + "listPublicId": "list-a", + "index": 4, + "labels": [ + {"publicId": "tag-1", "name": "Urgent", "color": "#FF0000"} + ] + } + } + } + """.trimIndent(), + 200 to """ + { + "card": { + "publicId": "card-1", + "title": "Instant date", + "description": "Desc", + "dueAt": "2026-03-16T23:30:00-02:00" + } + } + """.trimIndent(), + ), + ) + + val client = HttpKanbnApiClient() + val first = client.getCardDetail(server.baseUrl, "api", "card-1") + val second = client.getCardDetail(server.baseUrl, "api", "card-1") + + val firstDetail = (first as BoardsApiResult.Success).value + val secondDetail = (second as BoardsApiResult.Success).value + assertEquals(LocalDate.of(2026, 3, 16), firstDetail.dueDate) + assertEquals(LocalDate.of(2026, 3, 17), secondDetail.dueDate) + assertEquals("card-1", firstDetail.id) + assertEquals("Direct date", firstDetail.title) + assertEquals("Desc", firstDetail.description) + assertEquals("list-a", firstDetail.listPublicId) + assertEquals(4, firstDetail.index) + assertEquals("tag-1", firstDetail.tags.first().id) + } + } + + @Test + fun updateCard_usesFallbackOrder_andFinalFullPut() = runTest { + TestServer().use { server -> + server.registerSequence( + path = "/api/v1/cards/card-2", + method = "PATCH", + responses = listOf( + 400 to "{}", + 409 to "{}", + ), + ) + server.registerSequence( + path = "/api/v1/cards/card-2", + method = "PUT", + responses = listOf( + 400 to "{}", + 500 to "{}", + 200 to "{}", + ), + ) + server.register( + path = "/api/v1/cards/card-2", + method = "GET", + status = 200, + responseBody = + """ + { + "card": { + "publicId": "card-2", + "title": "Current title", + "description": "Current desc", + "index": 11, + "listPublicId": "list-old", + "dueDate": "2026-01-20T00:00:00Z" + } + } + """.trimIndent(), + ) + + val result = HttpKanbnApiClient().updateCard( + baseUrl = server.baseUrl, + apiKey = "api", + cardId = "card-2", + title = "New title", + description = "New desc", + dueDate = LocalDate.of(2026, 3, 18), + ) + + assertTrue(result is BoardsApiResult.Success<*>) + val requests = server.findRequests("/api/v1/cards/card-2") + assertEquals("PATCH", requests[0].method) + assertEquals("PATCH", requests[1].method) + assertEquals("PUT", requests[2].method) + assertEquals("PUT", requests[3].method) + assertEquals("GET", requests[4].method) + assertEquals("PUT", requests[5].method) + assertTrue(requests[0].body.contains("\"title\":\"New title\"")) + assertTrue(requests[0].body.contains("\"description\":\"New desc\"")) + assertTrue(requests[0].body.contains("\"dueDate\":\"2026-03-18T00:00:00Z\"")) + assertTrue(requests[1].body.contains("\"name\":\"New title\"")) + assertTrue(requests[1].body.contains("\"body\":\"New desc\"")) + assertTrue(requests[1].body.contains("\"dueAt\":\"2026-03-18T00:00:00Z\"")) + assertTrue(requests[2].body.contains("\"title\":\"New title\"")) + assertTrue(requests[3].body.contains("\"name\":\"New title\"")) + assertTrue(requests[5].body.contains("\"listPublicId\":\"list-old\"")) + assertTrue(requests[5].body.contains("\"index\":11")) + } + } + + @Test + fun updateCard_stopsFallbackOnAuthFailure() = runTest { + TestServer().use { server -> + server.register(path = "/api/v1/cards/card-auth", method = "PATCH", status = 401, responseBody = "{}") + + val result = HttpKanbnApiClient().updateCard( + baseUrl = server.baseUrl, + apiKey = "api", + cardId = "card-auth", + title = "Title", + description = "Desc", + dueDate = null, + ) + + assertTrue(result is BoardsApiResult.Failure) + val requests = server.findRequests("/api/v1/cards/card-auth") + assertEquals(1, requests.size) + assertEquals("PATCH", requests.first().method) + } + } + + @Test + fun listCardActivities_triesEndpointVariants_thenReturnsNewestFirstTopTen() = runTest { + TestServer().use { server -> + server.register( + path = "/api/v1/cards/card-3/activities?limit=50", + method = "GET", + status = 200, + responseBody = "{\"data\":\"not-an-array\"}", + ) + val secondPayloadItems = (1..12).joinToString(",") { index -> + val day = index.toString().padStart(2, '0') + """{"id":"a-$index","type":"comment","text":"Item $index","createdAt":"2026-01-${day}T00:00:00Z"}""" + } + server.register( + path = "/api/v1/cards/card-3/actions?limit=50", + method = "GET", + status = 200, + responseBody = "{\"data\":[${secondPayloadItems}]}", + ) + + val result = HttpKanbnApiClient().listCardActivities(server.baseUrl, "api", "card-3") + + val activities = (result as BoardsApiResult.Success>).value + assertEquals(10, activities.size) + assertEquals("a-12", activities.first().id) + assertEquals("a-3", activities.last().id) + val requests = server.findRequests("/api/v1/cards/card-3/activities?limit=50") + + server.findRequests("/api/v1/cards/card-3/actions?limit=50") + + server.findRequests("/api/v1/cards/card-3/card-activities?limit=50") + assertEquals(2, requests.size) + assertEquals( + LocalDate.of(2026, 1, 12).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(), + activities.first().createdAtEpochMillis, + ) + } + } + + @Test + fun addCardComment_fallbacksOnAmbiguousSuccess_andStopsOnLogicalFailure() = runTest { + TestServer().use { server -> + server.registerSequence( + path = "/api/v1/cards/card-4/comment-actions", + method = "POST", + responses = listOf( + 200 to "{\"maybe\":\"unknown\"}", + 200 to "{\"success\":false,\"message\":\"blocked\"}", + ), + ) + + val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-4", "A comment") + + assertTrue(result is BoardsApiResult.Failure) + val requests = server.findRequests("/api/v1/cards/card-4/comment-actions") + assertEquals(2, requests.size) + assertEquals("{\"text\":\"A comment\"}", requests[0].body) + assertEquals("{\"comment\":\"A comment\"}", requests[1].body) + assertTrue(server.findRequests("/api/v1/cards/card-4/actions/comments").isEmpty()) + } + } + + @Test + fun addCardComment_fallsBackToThirdEndpoint_andSucceedsOnCommentActionPayload() = runTest { + TestServer().use { server -> + server.register(path = "/api/v1/cards/card-5/comment-actions", method = "POST", status = 500, responseBody = "{}") + server.register(path = "/api/v1/cards/card-5/comment-actions", method = "POST", status = 400, responseBody = "{}") + server.register( + path = "/api/v1/cards/card-5/actions/comments", + method = "POST", + status = 200, + responseBody = "{\"commentAction\":{\"id\":\"x\"}}", + ) + + val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-5", "Looks good") + + assertTrue(result is BoardsApiResult.Success<*>) + val endpoint1Requests = server.findRequests("/api/v1/cards/card-5/comment-actions") + assertEquals(2, endpoint1Requests.size) + val endpoint3Requests = server.findRequests("/api/v1/cards/card-5/actions/comments") + assertEquals(1, endpoint3Requests.size) + assertEquals("{\"text\":\"Looks good\"}", endpoint3Requests.first().body) + } + } + + private data class CapturedRequest( + val method: String, + val path: String, + val body: String, + ) + + private class TestServer : AutoCloseable { + private val requests = CopyOnWriteArrayList() + private val responses = mutableMapOf>() + private val responseSequences = mutableMapOf>>() + private val running = AtomicBoolean(true) + private val serverSocket = ServerSocket().apply { + bind(InetSocketAddress("127.0.0.1", 0)) + } + private val executor = Executors.newSingleThreadExecutor() + + val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}" + + init { + executor.execute { + while (running.get()) { + val socket = try { + serverSocket.accept() + } catch (_: Throwable) { + if (!running.get()) { + break + } + continue + } + handle(socket) + } + } + } + + fun register(path: String, method: String, status: Int, responseBody: String) { + responses["${method.uppercase()} $path"] = status to responseBody + } + + fun registerSequence(path: String, method: String, responses: List>) { + responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses) + } + + fun findRequests(path: String): List { + return requests.filter { 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 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-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) + + val sequenceKey = "$effectiveMethod $path" + val sequence = responseSequences[sequenceKey] + val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null + val response = sequencedResponse ?: responses[sequenceKey] ?: (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" + 401 -> "Unauthorized" + 404 -> "Not Found" + 409 -> "Conflict" + 500 -> "Internal Server Error" + 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) + } + } +}