fix: continue card-detail fallbacks on 2xx incompatibility

This commit is contained in:
2026-03-16 20:29:12 -04:00
parent 70f1558ea3
commit eee2f9cb17
2 changed files with 62 additions and 36 deletions

View File

@@ -757,7 +757,8 @@ class HttpKanbnApiClient : KanbnApiClient {
when (evaluateCommentResponse(response.body)) {
CommentResponseClassification.Success -> return@withContext BoardsApiResult.Success(Unit)
CommentResponseClassification.LogicalFailure -> {
return@withContext BoardsApiResult.Failure(extractLogicalFailureMessage(response.body))
lastFailure = extractLogicalFailureMessage(response.body)
continue
}
CommentResponseClassification.ParseIncompatible -> continue
}
@@ -1175,31 +1176,31 @@ class HttpKanbnApiClient : KanbnApiClient {
}
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 == "[]") {
val items: List<Map<*, *>> = when (parsed) {
is List<*> -> {
if (parsed.isEmpty()) {
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) {
if (parsed.any { it !is Map<*, *> }) {
return null
}
@Suppress("UNCHECKED_CAST")
parsed as List<Map<*, *>>
}
is Map<*, *> -> {
val extracted = extractActivityContainer(parsed) ?: return null
if (extracted.any { it !is Map<*, *> }) {
return null
}
if (extracted.isEmpty()) {
return emptyList()
}
extracted.mapNotNull { it as? Map<*, *> }
}
else -> return null
}
return items.mapNotNull { raw ->
val id = extractString(raw, "id", "publicId", "public_id")
@@ -1218,13 +1219,24 @@ class HttpKanbnApiClient : KanbnApiClient {
}
}
private fun extractActivityArray(source: Map<*, *>): List<Map<*, *>> {
val topLevel = extractObjectArray(source, "activities", "actions", "cardActivities", "card_activities", "items", "data")
if (topLevel.isNotEmpty()) {
return topLevel
private fun extractActivityContainer(source: Map<*, *>): List<*>? {
val keys = listOf("activities", "actions", "cardActivities", "card_activities", "items", "data")
keys.forEach { key ->
if (source.containsKey(key)) {
val value = source[key]
return value as? List<*>
}
val data = source["data"] as? Map<*, *> ?: return emptyList()
return extractObjectArray(data, "activities", "actions", "cardActivities", "card_activities", "items", "data")
}
val data = source["data"] as? Map<*, *> ?: return null
keys.forEach { key ->
if (data.containsKey(key)) {
val value = data[key]
return value as? List<*>
}
}
return null
}
private fun parseEpochMillis(raw: String): Long? {

View File

@@ -166,7 +166,7 @@ class CardDetailApiCompatibilityTest {
}
@Test
fun listCardActivities_triesEndpointVariants_thenReturnsNewestFirstTopTen() = runTest {
fun listCardActivities_fallsBackToThirdEndpoint_whenFirstTwo2xxPayloadsAreUnparseable() = runTest {
TestServer().use { server ->
server.register(
path = "/api/v1/cards/card-3/activities?limit=50",
@@ -174,12 +174,18 @@ class CardDetailApiCompatibilityTest {
status = 200,
responseBody = "{\"data\":\"not-an-array\"}",
)
server.register(
path = "/api/v1/cards/card-3/actions?limit=50",
method = "GET",
status = 200,
responseBody = "{\"actions\":{\"unexpected\":true}}",
)
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",
path = "/api/v1/cards/card-3/card-activities?limit=50",
method = "GET",
status = 200,
responseBody = "{\"data\":[${secondPayloadItems}]}",
@@ -194,7 +200,7 @@ class CardDetailApiCompatibilityTest {
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(3, requests.size)
assertEquals(
LocalDate.of(2026, 1, 12).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
activities.first().createdAtEpochMillis,
@@ -203,25 +209,33 @@ class CardDetailApiCompatibilityTest {
}
@Test
fun addCardComment_fallbacksOnAmbiguousSuccess_andStopsOnLogicalFailure() = runTest {
fun addCardComment_continuesFallbackAfter2xxLogicalFailure_untilLaterVariantSucceeds() = 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\"}",
500 to "{}",
),
)
server.register(
path = "/api/v1/cards/card-4/actions/comments",
method = "POST",
status = 200,
responseBody = "{\"action\":{\"id\":\"act-1\"}}",
)
val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-4", "A comment")
assertTrue(result is BoardsApiResult.Failure)
assertTrue(result is BoardsApiResult.Success<*>)
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())
val thirdRequests = server.findRequests("/api/v1/cards/card-4/actions/comments")
assertEquals(1, thirdRequests.size)
assertEquals("{\"text\":\"A comment\"}", thirdRequests[0].body)
}
}