feat: add card detail API contracts and compatibility parsing

This commit is contained in:
2026-03-16 20:22:36 -04:00
parent 334a01fc79
commit fb5d9e1e5b
4 changed files with 928 additions and 4 deletions

View File

@@ -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:

View File

@@ -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<LabelDetail> {
return BoardsApiResult.Failure("Label detail is not implemented.")
}
suspend fun getCardDetail(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<CardDetail> {
return BoardsApiResult.Failure("Card detail is not implemented.")
}
suspend fun updateCard(
baseUrl: String,
apiKey: String,
cardId: String,
title: String,
description: String,
dueDate: LocalDate?,
): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Card update is not implemented.")
}
suspend fun listCardActivities(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<List<CardActivity>> {
return BoardsApiResult.Failure("Card activity listing is not implemented.")
}
suspend fun addCardComment(baseUrl: String, apiKey: String, cardId: String, comment: String): BoardsApiResult<Unit> {
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<CardDetail> {
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<Unit> {
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<List<CardActivity>> {
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<Unit> {
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 <T> 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<CardActivity>? {
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<Map<*, *>> {
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<BoardListDetail> {
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<String, Any?>? {
val parsed = parseJsonValue(body)
@Suppress("UNCHECKED_CAST")
return parsed as? Map<String, Any?>
}
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<String, Any?>
return runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull()
}
private fun jsonEscape(value: String): String {

View File

@@ -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<CardDetailTag>,
)
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,
)

View File

@@ -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<CardDetail>).value
val secondDetail = (second as BoardsApiResult.Success<CardDetail>).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<List<CardActivity>>).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<CapturedRequest>()
private val responses = mutableMapOf<String, Pair<Int, String>>()
private val responseSequences = mutableMapOf<String, ArrayDeque<Pair<Int, String>>>()
private val running = AtomicBoolean(true)
private val serverSocket = ServerSocket().apply {
bind(InetSocketAddress("127.0.0.1", 0))
}
private val executor = Executors.newSingleThreadExecutor()
val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}"
init {
executor.execute {
while (running.get()) {
val socket = try {
serverSocket.accept()
} catch (_: Throwable) {
if (!running.get()) {
break
}
continue
}
handle(socket)
}
}
}
fun register(path: String, method: String, status: Int, responseBody: String) {
responses["${method.uppercase()} $path"] = status to responseBody
}
fun registerSequence(path: String, method: String, responses: List<Pair<Int, String>>) {
responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses)
}
fun findRequests(path: String): List<CapturedRequest> {
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)
}
}
}