feat: add card detail API contracts and compatibility parsing
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user